Reliable Component Testing with Vitest's Browser Mode and Playwright

Oct 08, 2024 ยท 6 min read
Reliable Component Testing with Vitest's Browser Mode and Playwright

Vitest is great for unit testing. But for frontend components that rely on user interactions, browser events, and other visual states, unit testing alone is not enough. We also need to ensure the component looks and behaves as expected in an actual browser. And to simulate the browser environment, Vitest requires packages like JSDOM or HappyDOM, which are not always reliable as the real ones.

An alternative is to use Playwright's Component Testing. However, this solution requires separate setup and run, which can be cumbersome in many cases.

This is where Vitest's browser mode comes in.

Table of Contents

Prequisites

You should have a Vue project set up with Vue Router and Vitest. If you haven't done so, refer to this post to set up the essentisal Vitest testing environment for your Vue project.

Once ready, let's create our testing component SearchBox.

The SearchBox component

Our SearchBox component accepts a search term and syncs it with the URL query params. Its template is as follows:

  <label for="searchbox">Search</label>
  <input v-model="search" 
    placeholder="Search for a pizza" 
    data-testid="search-input" 
    id="searchbox" />

With the script section:

import { useRouter } from "vue-router";
import { useSearch } from "../composables/useSearch";
import { watch } from "vue";

const props = defineProps({
  searchTerm: {
    type: String,
    required: false,
    default: "",
  }
});

const router = useRouter();

const { search } = useSearch({
  defaultSearch: props.searchTerm,
});

watch(search, (value, prevValue) => {
  if (value === prevValue) return;
  router.replace({ query: { search: value } });
}, {
    immediate: true
});

And in the browser, it will look like this:

Search input box component

Next, we will set up the browser mode for Vitest.

Enable Vitest's browser mode with Playwright

In vitest.config.js, we will setup browser mode as below:

// vitest.config.js
/*...*/
defineConfig({
  test: {
    /**... */
    browser: {
      enabled: true,
      name: 'chromium',
      provider: 'playwright',
      providerOptions: {},
    },
  }
})

In which, we configure the following:

  • enabled: enable the browser mode
  • name: the browser to run the tests in (chromium)
  • provider: the test provider for running the browser, such as playwright
  • providerOptions: additional configuration for the test provider.

We also specify which folder (tests\browser) and the file convention to use, avoiding any conflicts with any existing regular unit tests:

// vitest.config.js
/*...*/
defineConfig({
  test: {
    /**... */
    include: 'tests/browser/**/*.{spec,test}.{js,ts}',
  }
})

With that, we are ready to write our first browser test for SearchBox.

In the tests/browser folder, we create a new file SearchBox.spec.js with the following code:

/**SearchBox.spec.js */
import { test, expect, describe } from 'vitest';
import SearchBox from "@/components/SearchBox.vue";

describe('SearchBox', () => {
  test('renders search input', async () => {
    /** Test logic here */
  });
});

To render SearchBox, we use render() from vitest-browser-vue, and pass the initial search term as a prop:

/**SearchBox.spec.js */
/**... */
import { render } from 'vitest-browser-vue';

describe('SearchBox', () => {
  test('renders search input', async () => {
    const component = await render(SearchBox, {
      props: {
        searchTerm: "hello",
      },
    });
  });
});

Since SearchBox is using router from useRouter() from Vue Router, we need the following router setup:

  • Create a mock router using createRouter():
    /** SearchBox.spec.js */
    /**... */
    import { routes } from '@/router';
    import { createRouter, createWebHistory } from 'vue-router';
    
    const router = createRouter({
      history: createWebHistory(),
      routes: routes,
    })
    
  • Pass it as a global plugin to render():
    /** SearchBox.spec.js */
      test('renders search input', async () => {
        const component = await render(SearchBox, {
          /**... */, 
          global: {
            plugins: [router]
          }
        });
      });
    

Once done, we locate the input element by its data-testid, and assert its initial value using toHaveValue():

  test('renders search input', async () => {
    /**... */
    const input = await component.getByTestId('search-input')

    await expect(input.element()).toHaveValue('hello')
  });

Note here input received is just a Locator and not a valid HTML element. We need input.element() to get the HTML instance. Otherwise, Vitest will throw the below error:

Error of value needed to be HTML or SVG element

To change the input's value, we use input.fill():

  test('renders search input', async () => {
    /**... */
    await input.fill('test')
  });

Alternatively, we can use userEvent() from @vitest/browser/context as follows:

import { userEvent } from "@vitest/browser/context"

/**... */
  test('renders search input', async () => {
    /**... */    
    await userEvent.fill(input, 'test')
  });

Both approaches perform the same. We can then assert the new value as usual:

await expect(input.element()).toHaveValue('test')

That's it! We have successfully written our first browser test.

At this point, we have one test configuration set for our Vitest runner. This setup will be problematic when Vitest need to run both unit and browser tests together in an automation workflow. For such cases, we use workspace and separate the settings per test type, which we explore next.

Using the workspace configuration file

We create a new file vitest.workspace.js to store the workspace configurations as follows:

import { defineWorkspace } from 'vitest/config'

export default defineWorkspace([
  {
    extends: 'vitest.config.js',
    test: {
      environment: 'jsdom',
      name: 'unit',
      include: ['**/*/unit/*.{spec,test}.{js,ts}'],
    },
  },
])

In which, we define the first configuration for unit tests using jsdom, based on the existing vitest.config.js settings. We also specify the folder and file convention for the unit tests.

Similarly, we define the second configuration for browser tests using playwright:

export default defineWorkspace([
  /** ... */,
  {
    extends: 'vitest.config.js',
    test: {
      include: ['**/*/browser/*.{spec,test}.{js,ts}'],
      browser: {
        enabled: true,
        name: 'chromium',
        provider: 'playwright',
      },
      name: 'browser'
    },
  },
])

And with that, we can run all our tests in a single command, which we will see next.

Run and view the results

We add the following command to our package.json:

{
  "scripts": {
    "test": "vitest --workspace=vitest.workspace.js"
  }
}

Upon executing yarn test, Vitest runs the tests based on vitest.workspace.js and displays the results in a GUI dashboard as follows:

Dashboard of Vitest result with each test labeled by type

Vitest labels each test by unit or browser status. We can then filter the tests by their statuses, or perform further debugging with the given browser UI per test suite.


Summary

We have learned how to set up browser mode for Vitest using Playwright, and write the first browser test. We have also explored how to take screenshots for visual testing, and use the workspace configuration to separate the settings per testing mode. One big limitation of Vitest's browser mode in comparison to Playwright's Component Testing is the lack of browser's address bar, limiting us from testing the component's state synchronization with URL query params in the browser. But it's a good start to build a scalable testing strategy for our Vue projects.

๐Ÿ‘‰ Learn about Vue 3 and TypeScript with my new book Learning Vue!

๐Ÿ‘‰ Follow me on X | LinkedIn.

Like this post or find it helpful? Share it ๐Ÿ‘‡๐Ÿผ or Buy me a coffee