Post

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:

  1. npm parses your package.json, looks in the scripts section to find the configured dev command.
  2. npm runs the dev command (in this case vite) in the context of your currently installed node_modules.
  3. Vite loads its config file vite.config.ts and starts an http server rooted at index.html, the default entrypoint for the new Vite project. - Notice the vue 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 the src/main.ts script (dynamically transpiled to JS by Vite) which creates the Vue app and mounts it at the previously defined div.

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 to false excludes files in the public folder from the build. For libraries, that’s typically the preferred option since public 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 the build.lib.name key.

Now, running npm run build will generate a bundle in the dist folder containing your library code instead of the src code!

TypeScript Support in the IDE

Right now, TypeScript doesn’t work in our library 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 package jackspeak (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 by npm 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!

This post is licensed under CC BY 4.0 by the author.