Automatic Component Testing with Vitest and Testing Library

- Lire en français
Resources: huchet-vue

Testing is an essential part of software development. It ensures our components work as expected and helps us catch bugs early in the development process. In this article, we'll explore how to automatically test Vue.js components using Vitest and Testing Library.

When working on a Vite project, Vitest is the best choice to test our components. It's a test runner optimized for Vite and is very easy to use. Testing Library builds on Vue Test Utils and provides a set of utilities to test our components in a user-centric way.

For example, Testing Library encourages us to test the behavior of our components instead of their implementation. This is a powerful concept that allows us to test components in a way that is closer to the user experience and makes refactoring easier.

Setting Up Vitest

To get started, we need to install Vitest and Testing Library in our packages/huchet-vue package:

bash
pnpm add -D vitest @testing-library/vue jsdom

Since we are testing frontend components, we need a DOM environment. To avoid launching a real browser, which is slow and resource-consuming, we can use JSDOM, a lightweight implementation of the DOM in Node.js.

To make Vitest work with JSDOM, we need to configure it in the vite.config.ts file:

ts
/// <reference types="vitest" />

import { defineConfig } from 'vite'

export default defineConfig({
  // ...

  test: {
    globals: true,
    environment: 'jsdom',
  },
})

Note

/// <reference types="vitest" /> is crucial to ensure the correct types are available in the Vite configuration file. It augments the Vite configuration file with the Vitest types.

In the package.json file of packages/huchet-vue, add two new scripts to run the tests:

json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

The first script runs the tests once, and the second script runs the tests in watch mode if it does not detect a CI environment.

TypeScript Configuration

Our test files form a new TypeScript context. We need to access the Node.js API but not the browser environment. As with any new TypeScript context, we configure it through a new TSConfig file.

Create a new tsconfig.test.json file that extends from tsconfig.node.json and includes the test.ts files:

json
{
  "extends": "./tsconfig.node.json",
  "include": ["src/**/*.test.ts"]
}

In the tsconfig.app.json, we exclude the test.ts files, similar to how we excluded the story.vue files:

json
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": [
    "src/**/*.ts",
    "src/**/*.vue"
  ],
  "exclude": [
    "src/**/*.story.vue",
    "src/**/*.test.ts"
  ]
}

In our main TSConfig file, we add a reference to the tsconfig.test.json file:

json
{
  "files": [],
  "references": [
    {
      "path": "./tsconfig.app.json"
    },
    {
      "path": "./tsconfig.node.json"
    },
    {
      "path": "./tsconfig.story.json"
    },
    {
      "path": "./tsconfig.test.json"
    }
  ]
}

Writing Our First Test

Every component is isolated and encapsulated in its folder from the start. Testing will follow the same structure. Create a src/Button/Button.test.ts file and write our first test:

bash
touch src/Button/Button.test.ts
ts
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import Button from './Button.vue'

describe('button', () => {
  it('should emit a click event when clicked', async () => {
    const { emitted } = render(Button, {
      props: {
        label: 'Hello',
      },
    })

    await fireEvent.click(screen.getByText('Hello'))

    expect(emitted()).toMatchInlineSnapshot(`
      {
        "click": [
          [
            MouseEvent {
              "isTrusted": false,
            },
          ],
        ],
      }
    `)
  })

  describe('solid', () => {
    it('should render a solid button', () => {
      const button = render(Button, {
        props: {
          label: 'Hello',
          variant: 'solid',
        },
      })

      expect(button.html()).toMatchSnapshot()
    })
  })

  describe('outline', () => {
    it('should render an outline button', () => {
      const button = render(Button, {
        props: {
          label: 'Hello',
          variant: 'outline',
        },
      })

      expect(button.html()).toMatchSnapshot()
    })
  })
})

In the first test, we use the render function from Testing Library to render our Button component. We also use the fireEvent function to simulate a click event on the button. Finally, we utilize the expect function from Vitest to assert that the component emits a click event when clicked.

In the following tests, we verify the solid and outline variants of the button. We use the toMatchSnapshot function from Vitest to assert that the component renders correctly.

These tests reflect two key approaches I prefer when testing components:

  1. Testing the behavior of the component. For instance, does it emit the correct events when clicked?
  2. Testing the appearance of the component. For example, does it render correctly with different variants?

Most of the time, these two types of tests are sufficient to ensure our components work as expected, and they can be easily achieved using snapshot testing.

Tip

Snapshot testing is a powerful tool that allows us to capture the output of our components and compare it against a reference snapshot. This is very useful for checking the appearance of our components, ensuring they render correctly, and avoiding manually writing the expected HTML.

We can run our tests with the following command:

bash
pnpm run test

Everything works as expected!

bash
 DEV  v2.1.5 /huchet-vue/packages/huchet-vue

 src/Button/Button.test.ts (3)
 button (3)
 should emit a click event when clicked
 solid (1)
 should render a solid button
 outline (1)
 should render an outline button

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  00:00:00
   Duration  536ms (transform 42ms, setup 0ms, collect 142ms, tests 22ms, environment 237ms, prepare 34ms)

Typechecking Tests

Besides tests, we can ensure every type in the project is correct. This is a straightforward way to detect errors before they happen, but it doesn't replace the tests. We can add a new script to the package.json file to run the typecheck:

json
{
  "scripts": {
    "typecheck": "vue-tsc -p tsconfig.app.json --noEmit"
  }
}

This script uses the vue-tsc command to typecheck the project within the context of the tsconfig.app.json file. The --noEmit option prevents transpiling the TypeScript files into JavaScript files, focusing solely on typechecking without building.

Profil Picture of Estéban

Thanks for reading! My name is Estéban, and I love to write about web development.

I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.

If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.

I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!

Continue readingDelivering Components Continuously and with Confidence