How to create a custom Vue Component Library with robust testing via Storybook and Vitest
Many developers are familiar with using a component library (e.g. Vuetify, Quasar, PrimeVue, etc.), but there comes a time when it’s useful to create your own.
- Multiple projects which should share custom components.
- A large project which is hard to test.
This comprehensive, step-by-step guide will take you from a new, empty folder all the way to a fully-functional component library with robust automated testing tools which follow software engineering best practices. The techniques described here are neither theoretical nor a mere “proof-of-concept” – they have been used successfully in critical production applications.
Let’s get started!
Steps 1, 3, and 4 of this guide are heavily inspired by Andreas Riedmüller’s excellent tutorial for creating a React Component Library
1. Scaffold an empty Vite project
Vite is a fabulous build tool that works especially well with Vue projects like a Vue Component Library.
Run npm create vite@latest
to scaffold a new project. You’ll be asked for a project name, then choose the Vue framework and the TypeScript variant to complete the setup.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ npm create vite@latest
Need to install the following packages:
create-vite@5.2.1
Ok to proceed? (y) y
✔ Project name: … my-component-library
✔ Select a framework: › Vue
✔ Select a variant: › TypeScript
Scaffolding project in /my-component-library...
Done. Now run:
cd my-component-library
npm install
npm run dev
Run the recommended commands to verify that installs and works correctly.
1
2
3
4
5
6
7
8
$ npm install
added 46 packages, and audited 47 packages in 9s
5 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
1
2
3
4
5
6
7
$ npm run dev
VITE v5.1.4 ready in 329 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
Open your browser to the Local
URL shown in your console to see the your new webpage powered by Vite!
To wrap up scaffolding, let’s do a few tiny polishing steps…
Install TypeScript types for NodeJS APIs
Currently, newly scaffolded Vite projects do not include the TypeScript type declarations for built-in NodeJS APIs. We’ll need these later in the guide.
1
npm install --save-dev @types/node
Create an .nvmrc
file (optional)
Many developers (and CI tools) use nvm
(Node Version Manager) to easily configure the correct NodeJS version for each project. Creating an .nvmrc
file clearly communicates your project’s preferred version of node
.
1
node --version > .nvmrc
Make a git
commit (optional)
At this point, I like to make a commit to snapshot my progress
1
2
git add .
git commit --message "chore: Scaffold new Vite project"
Committing immediately after scaffolding gives two major benefits: first, I can easily revert back to the scaffolded version if I mess up a later step; and second, a git blame
can distinguish default configurations from custom configurations.
2. Create some sample library code
Let’s add some placeholder code to our library! The goal at this point is have just enough library code to meaningfully evaluate the build tooling we will add in later steps. As such, I strongly recommend starting with only trivial library code so you can focus any debugging efforts on the build tooling.
For this tutorial, we’ll use a simple Greeting
component which calls a utility function to generate its greeting.
Create a custom utility function
1
2
3
export function createGreeting(name?: string): string {
return name === undefined ? "Hello!" : `Hello ${name}!`
}
And create a custom component
1
2
3
4
5
6
7
8
9
<script setup lang="ts">
import { createGreeting } from '../utils/createGreeting.ts'
defineProps<{ name: string }>()
</script>
<template>
<h1>{{ createGreeting(name) }}</h1>
</template>
For now, ignore the TypeScript error on the import line. We’ll fix that in a later step.
Finally, export your component from your library
1
export * as Greeting from "./components/Greeting.vue"
At this point, if you run npm run build
you’ll notice that the generated dist
directory contains none of the library code you just wrote. We’ll fix the build in the next step.
3. Convert Vite to library mode
A newly scaffolded Vite project always starts in project mode (i.e. building a complete web application), but, with a little more configuration, Vite also supports building JavaScript libraries.
How does Vite's project mode work?
Understanding the “magic” behind Vite’s project mode isn’t required for building a component library. But that knowledge does make it easier to understand the changes made to support library mode in Vite.
When you run npm run dev
, here’s what happens behind the scenes:
npm
parses yourpackage.json
, looks in thescripts
section to find the configureddev
command.npm
runs thedev
command (in this casevite
) in the context of your currently installednode_modules
.- Vite loads its config file
vite.config.ts
and starts an http server rooted atindex.html
, the default entrypoint for the new Vite project. - Notice thevue
plugin which enables Vite to transpile.vue
files to plain JS.
Opening a web browser loads the index.html
like a normal webpage, opening your web application.
- The
index.html
defines an empty<div id="app"></div>
(where the web application will mount). - The
<script type="module" src="/src/main.ts"></script>
loads thesrc/main.ts
script (dynamically transpiled to JS by Vite) which creates the Vue app and mounts it at the previously defineddiv
.
You’ll notice that if you run npm run build
, Vite will create a dist
directory containing index.html
(the default entrypoint) and the optimized code from your src
directory. The key difference for library mode is telling Vite to build a different entrypoint to your code.
Create a library entrypoint
In Step 2, we created a separate lib
folder for our library code. We left the index.html
file and src
directory alone since they are a helpful playground to use while building components.
Now we need to add the library entrypoint to vite.config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineConfig } from 'vite'
+ import { resolve } from 'path'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
+ build: {
+ copyPublicDir: false,
+ lib: {
+ entry: resolve(__dirname, 'lib/main.ts'),
+ formats: ['es']
+ }
+ }
})
Setting
build.copyPublicDir
tofalse
excludes files in thepublic
folder from the build. For libraries, that’s typically the preferred option sincepublic
is intended to hold static assets for a webpage.
The default value for
build.lib.formats
is['es', 'umd']
. The'umd'
format is mostly used for standalone JS files distributed via a CDN, so it isn’t necessary for a component library. Omitting'umd'
also has the benefit of allowing us to omit thebuild.lib.name
key.
Now, running npm run build
will generate a bundle in the dist
folder containing your lib
rary code instead of the src
code!
TypeScript Support in the IDE
Right now, TypeScript doesn’t work in our lib
rary code because the default configuration only looks at the src
code. Let’s fix that!
Copy Vite’s types to your library
1
cp src/vite-env.d.ts lib/vite-env.d.ts
Enable TypeScript in your IDE for the lib
directory
1
2
- "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
+ "include": ["src", "lib"],
TypeScript should now be working in your IDE!
Add TypeScript declarations to the built library
We also want to include TypeScript type declarations in the built library so that library users also benefit from the IDE autocompletion and quality checks. However, we want to omit type declarations for src
files (our dev playground) since the src
code won’t be there either.
Start by creating a separate TypeScript config for building the library
1
2
3
4
{
"extends": "./tsconfig.json",
"include": ["lib"]
}
And update your package.json
’s build
script to use the new TypeScript config. Now your build will only run type checks for code in your lib
directory.
1
2
3
4
"scripts": {
…
- "build": "vue-tsc && vite build",
+ "build": "vue-tsc --p ./tsconfig-build.json && vite build",
To distribute TypeScript type declarations with your library, install the Vite plugin called vite-plugin-dts
and point it to the tsconfig-build.json
you just created.
1
npm add --save-dev vite-plugin-dts
1
2
3
4
5
6
7
+import dts from 'vite-plugin-dts'
…
plugins: [
vue(),
+ dts({ tsconfigPath: "tsconfig-build.json" }),
],
…
Now, npm run build
should include several .d.ts
files in your dist
folder containing the TypeScript declarations for your library code!
4. Optimize the build
At this point, the built library bundle is nearly 50kb, a huge amount of code for such a simple component library. The reason for the excessive size is that our build is packaging in a copy of Vue. Since our component library will be used within projects which already have Vue installed, this additional bundling is entirely unnecessary.
Remove Vue from the bundle
Let’s update our Vite config to exclude Vue from the built library bundle
1
2
3
4
5
6
build: {
…
+ rollupOptions: {
+ external: ['vue'],
+ }
}
We’ll also want to mark Vue as a peer dependency so that our library users are reminded that they need to install Vue themselves.
1
2
3
4
- "dependencies": {
+ "peerDependencies": {
"vue": "^3.4.19"
},
You’ll also want to add Vue as a development dependency so it continues to be installed while you develop the library.
1
npm install --save-dev vue
Now npm run build
produces a much more reasonably sized bundle of only ~0.5kb, a 100x size reduction!
5. Add testing tools to verify software quality
Add Storybook.js for Component Driven Development
As we add more components to our library, it is helpful to have a dedicated tool to preview and test our components. The most powerful of these tools is Storybook.js.
Install Storybook
You can install Storybook using their installation guide for Vue projects, but I’ll repeat the key steps here for completeness.
NOTE: At the time of writing, there is a shortcoming with the current Storybook install script. You may have to force
npm
to resolve the packagejackspeak
(one of Storybook’s transitive dependencies) to a specific version.
1 2 3 4"dependencies": { … d} + "resolutions": { + "jackspeak": "2.1.1" + }
Install Storybook.js
1
npx storybook@latest init
Storybook should automatically configure itself and launch in your browser at https://localhost:6006. Explore the tool to get a feel for what it does and how it works.
Add a Story for a library component
Stories are written in files named *.stories.ts
and, by convention, are placed next to the component they test.
Create a Story file for the Greeting.vue
component we created at the beginning of the tutorial.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import type { Meta, StoryObj } from '@storybook/vue3';
import Greeting from './Greeting.vue';
// CONFIGURATION
const meta = {
title: 'components/Greeting',
component: Greeting,
tags: ['autodocs'],
} satisfies Meta<typeof Greeting>;
export default meta;
type Story = StoryObj<typeof meta>;
// STORIES
export const Default: Story = {
args: {},
};
export const HelloWorld: Story = {
args: {
name: 'World',
},
};
To show this story within Storybook in your browser, you’ll have to tell Storybook to look for stories in the lib
folder. For this tutorial, we won’t be writing stories for anything in the src
folder so we’ll have Storybook ignore src
entirely.
1
2
3
4
const config: StorybookConfig = {
- stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
+ stories: ["../lib/**/*.mdx", "../lib/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
…
Restart Storybook for this config change to take effect.
1
npm run storybook
Your Greeting
story should now available in your Storybook!
Add testing addons to Storybook
Storybook supports a rich ecosystem of addons for testing your components.
Install the Accessibility Addon
I like starting with the Accessibility addon since it provides a solid foundation for later testing plugins (particularly interaction testing).
1
npm install --save-dev @storybook/addon-a11y
1
2
3
4
5
6
7
8
const config: StorybookConfig = {
…
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
+ "@storybook/addon-a11y",
],
Now your stories will have an Accessibility tab which automatically checks your component for Accessibility issues and recommends fixes.
Install the Storybook Test Runner
The Storybook Test Runner turns all of your stories into automated unit, smoke, and interaction tests, no extra effort required!
Install the test runner
1
npm install --save-dev @storybook/test-runner @storybook/testing-library
Install playwright
(used by the test runner for smoke and interaction tests)
1
npx playwright install
Start Storybook in one terminal
1
npm run storybook
And run the tests against that Storybook instance in another terminal
1
npm run test-storybook
Add Storybook Test Coverage
Measuring your test coverage – which lines of production code get executed while running the test suite – can help you find gaps in your testing.
To have Storybook collect test coverage during its test runs, install the Storybook Test Coverage Addon
1
npm install --save-dev @storybook/addon-coverage
You’ll also need to include the coverage addon in your Storybook configuration
1
2
3
4
5
6
7
8
9
const config: StorybookConfig = {
…
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-a11y",
+ "@storybook/addon-coverage",
],
To measure test coverage in your .vue
files, you’ll also need to a .nycrc.json
config file instructing the test runner to instrument .vue
files.
1
touch .nycrc.json
1
{ "extension": [".vue",".js","jsx",".ts","tsx"] }
Restart your Storybook instance if necessary, then rerun the test suite, this time with the --coverage
flag.
1
npm run test-storybook -- --coverage
The coverage metrics will be saved to coverage/storybook/coverage-storybook.json
. You can generate a coverage report using nyc
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ npx nyc report --reporter=text --temp-dir coverage/storybook
--------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
components | 100 | 100 | 100 | 100 |
Greeting.vue | 100 | 100 | 100 | 100 |
utils | 100 | 100 | 100 | 100 |
createGreeting.ts | 100 | 100 | 100 | 100 |
--------------------|---------|----------|---------|---------|-------------------
$ npx nyc report --reporter=text-summary --temp-dir coverage/storybook
=============================== Coverage summary ===============================
Statements : 100% ( 4/4 )
Branches : 100% ( 2/2 )
Functions : 100% ( 1/1 )
Lines : 100% ( 4/4 )
================================================================================
Add Vitest for unit testing for utility functions
Your library may include utility functions in addition to UI components. To test these utility functions directly, you can add a unit testing framework like Vitest.
Install vitest
1
npm install --save-dev vitest
Add a test
script to your package.json
1
2
3
4
scripts: {
…
+ "test:unit": "vitest"
}
Add a test file for the createGreeting.ts
utility function we created at the start of the tutorial
1
2
3
4
5
6
7
8
9
10
11
import { describe, expect, it } from "vitest"
import { createGreeting } from "../createGreeting"
describe("createGreeting", () => {
it("creates a generic greeting when no name is provided", () => {
expect(createGreeting()).toBe("Hello!")
})
it("creates a specific greeting when a name is provided", () => {
expect(createGreeting("World")).toBe("Hello World!")
})
})
Now you can run the unit tests for your utility code
1
npm run test:unit
Setup Test Coverage Measurement for Vitest
Create a vitest.config.ts
with the following contents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineConfig } from 'vitest/config'
import vue from "@vitejs/plugin-vue"
import tsconfigPaths from "vite-tsconfig-paths"
export default defineConfig({
test: {
coverage: {
all: true, // Include files with 0% coverage in the report.
provider: 'istanbul', // or 'v8'
include: ['lib/**/*'],
// Omit test code from the report (both Storybook and Vitest)
exclude: ['lib/**/*.stories.{ts,tsx}', 'lib/**/*.test.{ts,tsx}'],
reporter: ["text", "text-summary", "json", "json-summary"],
reportsDirectory: "coverage/unit",
},
},
plugins: [
vue(),
tsconfigPaths(),
],
})
Install vite-tsconfig-paths
so Vitest can better support TypeScript resolutions.
1
npm install --save-dev vite-tsconfig-paths
Run the unit tests with the --coverage
flag to see the coverage results. If you are prompted to install @vitest/coverage-istanbul
, accept it, then re-run the test command.
1
npm run test:unit -- --coverage
Combine Test Coverage Results from Storybook and Vitest
Since we’re running two different testing tools, we get two separate, and thus incomplete, test coverage reports. We want to merge those reports into one complete test coverage report.
Currently, the data backing the Storybook test coverage report is saved in the coverage/storybook/
folder while Vitest’s test coverage data is in coverage/unit/
The underlying CLI tool for test coverage, nyc
, has a built-in merge
command which will merge all test coverage reports found in a specified directory. As a result, we can use the following script to generate our combined report
1
2
3
4
mkdir --parents coverage/merged
cp coverage/storybook/coverage-storybook.json coverage/merged/coverage-storybook.json
cp coverage/unit/coverage-final.json coverage/merged/coverage-unit.json
npx nyc merge coverage/merged coverage/coverage-final.json
Now you can view a complete, combined test coverage report:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ npx nyc report --reporter=text --temp-dir coverage/
--------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
components | 100 | 100 | 100 | 100 |
Greeting.vue | 100 | 100 | 100 | 100 |
utils | 100 | 100 | 100 | 100 |
createGreeting.ts | 100 | 100 | 100 | 100 |
--------------------|---------|----------|---------|---------|-------------------
$ npx nyc report --reporter=text-summary --temp-dir coverage/
=============================== Coverage summary ===============================
Statements : 100% ( 4/4 )
Branches : 100% ( 2/2 )
Functions : 100% ( 1/1 )
Lines : 100% ( 4/4 )
================================================================================
Running Tests in a Continuous Integration System
Running your tests regularly in an automated environment helps you find and fix errors in your code faster.
To run our tests in a continuous integration system (e.g. GitHub Actions), we need to write scripts that:
- Run the tests headlessly (i.e. with zero user interaction).
- Specify all dependencies required for the production code and its tests.
Run headless tests locally
Let’s start by updating the scripts in package.json
to clearly specify whether they run interactively (for local development) or headlessly (for use in ci
).
1
2
3
4
5
6
7
8
9
10
"scripts": {
+ "cdd": "storybook dev --port 6006",
+ "test:unit": "vitest",
+ "test:unit:ci": "vitest --coverage --run",
+ "test:cdd": "test-storybook --watch --browsers chromium,firefox,webkit",
+ "test:cdd:ci": "test-storybook --coverage --browsers chromium,firefox,webkit",
+ "build": "vue-tsc -p ./tsconfig-build.json && vite build --watch",
+ "build:ci": "vue-tsc -p ./tsconfig-build.json && vite build",
+ "build:cdd": "storybook build",
}
I use the name
cdd
(shorthand for Component Driven Development) to refer to my Storybook scripts because I prefer to focus on engineering principles over the tool that enables them.
From there, we can write the scripts to use in CI. I like to use a Makefile
for this purpose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
.PHONY: build
build:
npm run build
.PHONY: build-ci
build-ci:
npm run build:ci
.PHONY: test
test:
npx --yes concurrently --kill-others --success first --names "CDD,UNIT" -c "magenta,blue" \
"npx --yes npm run test:cdd" \
"npx --yes npm run test:unit"
.PHONY: test-ci
test-ci:
rm -rf coverage
# Run the unit tests
npm run test:unit:ci
# Run the Storybook tests
# - Credits: https://storybook.js.org/docs/writing-tests/test-runner#run-against-non-deployed-storybooks
npx --yes concurrently --kill-others --success first --names "SB,CDD" -c "magenta,blue" \
"npx --yes npm run cdd -- --port 6006 --ci --quiet" \
"npx --yes wait-on tcp:6006 && npm run test:cdd:ci"
# Merge the coverage reports
mkdir --parents coverage/merged
cp coverage/storybook/coverage-storybook.json coverage/merged/coverage-storybook.json
cp coverage/unit/coverage-final.json coverage/merged/coverage-unit.json
npx nyc merge coverage/merged coverage/coverage-final.json
# Generate the coverage report
for reporter in text text-summary lcov json-summary ; do \
npx nyc report --reporter=$$reporter -t coverage/ --report-dir coverage/ ; \
done
With this Makefile
you now have four useful commands for local development and CI verification
1
2
3
4
$ make build # Run the production build in *watch* mode (i.e. rebuild on file saves)
$ make build-ci # One-off, headless production build
$ make test # Run both unit/cdd tests in *watch* mode (i.e. rerun on file save)
$ make test-ci # One-off, headless test run
The magic part of these scripts is the use of the concurrently
package to boot a Storybook server headlessly for the Storybook testing library to run against (thanks Storybook docs!).
Run tests in GitHub Actions
If you host your component library repository in GitHub, you can create a GitHub Actions workflow file which will run the tests for you on every commit you push up.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
name: "CI(branch): Test, Release"
on:
push:
branches: [master, qa, dev, "[0-9]+.[0-9]+.[0-9]+", "[0-9]+.[0-9]+.[0-9]+-[a-zA-Z]+"]
tags:
- "[0-9]+.[0-9]+.[0-9]+"
pull_request:
branches: [master, qa, dev, "[0-9]+.[0-9]+.[0-9]+"]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Install Playwright
run: npx playwright install --with-deps
- name: Build the package
run: make build-ci
- name: Run the Unit Tests
run: make test-ci
- name: Publish internal package releases
# Run only on `push` events to tags/branches other than `master`
if: github.ref_name != 'master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
run: |
echo "NO-OP: Replace with your code for publishing an internal version"
- name: Push the production package releases
# Only run this job for the `master` branch
if: github.ref_name == 'master'
run: |
echo "NO-OP: Replace with your code for publishing a production version"
6. Other Notes
Hot-reloading the Vue component library within a larger project
My original motivation for creating a component library was to improve the testability of a large Vue application, not to share components between multiple applications. In that situation, my team wanted to preserve the ability to edit the component library and see it hot-reload within the large Vue application.
Attempting to hot-reload code changes to an npm dependency is extraordinarily difficult.
- The dependency code is usually an optimized, compiled form of the original source code, so a hot-reload requires rebuilding the original source code. Vite’s
build --watch
mode impressively managed to effectively tree-shake the build process so that it only rebuilt the smallest number of JS chunks possible; however, even that optimized build still took 3-4 seconds for tiny code changes (e.g. changing a static text label). - npm dependencies are loaded from
node_modules
, which is only updated bynpm install
. Any hot-reload process would also need to automatically re-install the component library.
For these reasons, we decided to import our component library code directly (i.e. not as an npm dependency) and let our larger Vue project’s compiler handle the entire build process for both the library code and the main application code.
Include your component library within the larger project as a submodule
1
2
git submodule init
git submodule add COMPONENT_LIBRARY_URL lib/COMPONENT_LIBRARY
Within the larger project, configure resolution aliases for your component library’s code.
Add a TypeScript alias
1
2
3
4
5
6
7
8
9
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
+ "my-component-lib/*": ["lib/my-component-lib/lib/*"]
}
}
}
Add a Vite alias
1
2
3
4
5
6
7
8
9
10
import { defineConfig } from "vite";
+ import path from "path"
export default defineConfig({
resolve: {
alias: {
+ "my-component-lib": path.join(__dirname, "./lib/my-component-lib/lib"),
}
}
})
Now your larger Vue project can directly import code from your component library and have the library code hot-reload/build just like the code of the larger application.
1
import Greeting from "my-component-lib/components/Greeting.vue"
Conclusion
Testing UI code is can be really hard, especially when attempting to improve the testability of a large, established project. Creating a custom component library can (1) provide a clean environment for developing and testing individual components and (2) open the door to sharing code with between projects for further productivity gains.
I hope this tutorial has helped you create a robustly testable component library which can provide a solid foundation for your development work for years to come.
Please share your experience, insights, and/or critiques in the comments below!