Skip to content

How to Do Visual Regression Testing in Vue with Vitest?

Published: at 

TL;DR: Visual regression testing detects unintended UI changes by comparing screenshots. With Vitest’s experimental browser mode and Playwright, you can:

In this guide, you’ll learn how to set up visual regression testing for Vue components using Vitest.

Our test will generate this screenshot:

Screenshot showing all button variants rendered side by side
Generated screenshot of all button variants in different states and styles

📖 Definition

Visual regression testing captures screenshots of UI components and compares them against baseline images to flag visual discrepancies. This ensures consistent styling and layout across your design system.

Vitest Configuration

Start by configuring Vitest with the Vue plugin:

import { import defineConfigdefineConfig } from 'vitest/config'
import import vuevue from '@vitejs/plugin-vue'

export default import defineConfigdefineConfig({
  plugins: any[]plugins: [import vuevue()],
})

Setting Up Browser Testing

Visual regression tests need a real browser environment. Install these dependencies:

npm install -D vitest @vitest/browser playwright

You can also use the following command to initialize the browser mode:

npx vitest init browser

First, configure Vitest to support both unit and browser tests using a workspace file, vitest.workspace.ts. For more details on workspace configuration, see the Vitest Workspace Documentation.

💪 Pro Tip

Using a workspace configuration allows you to maintain separate settings for unit and browser tests while sharing common configuration. This makes it easier to manage different testing environments in your project.


import { import defineWorkspacedefineWorkspace } from 'vitest/config'

export default import defineWorkspacedefineWorkspace([
  {
    extends: stringextends: './vitest.config.ts',
    
test: {
    name: string;
    include: string[];
    exclude: string[];
    environment: string;
}
test
: {
name: stringname: 'unit', include: string[]include: ['**/*.spec.ts', '**/*.spec.tsx'], exclude: string[]exclude: ['**/*.browser.spec.ts', '**/*.browser.spec.tsx'], environment: stringenvironment: 'jsdom', }, }, { extends: stringextends: './vitest.config.ts',
test: {
    name: string;
    include: string[];
    browser: {
        enabled: boolean;
        provider: string;
        headless: boolean;
        instances: {
            browser: string;
        }[];
    };
}
test
: {
name: stringname: 'browser', include: string[]include: ['**/*.browser.spec.ts', '**/*.browser.spec.tsx'],
browser: {
    enabled: boolean;
    provider: string;
    headless: boolean;
    instances: {
        browser: string;
    }[];
}
browser
: {
enabled: booleanenabled: true, provider: stringprovider: 'playwright', headless: booleanheadless: true,
instances: {
    browser: string;
}[]
instances
: [{ browser: stringbrowser: 'chromium' }],
}, }, }, ])

Add scripts in your package.json

{
  "scripts": {
    "test": "vitest",
    "test:unit": "vitest --project unit",
    "test:browser": "vitest --project browser"
  }
}

Now we can run tests in separate environments like this:

npm run test:unit
npm run test:browser

The BaseButton Component

Consider the BaseButton.vue component a reusable button with customizable size, variant, and disabled state:

<template>
  <button: ButtonHTMLAttributes & ReservedPropsbutton
    HTMLAttributes.class?: any:HTMLAttributes.class?: anyclass="[
      'button',
      `button--${size?: "small" | "medium" | "large" | undefinedsize}`,
      `button--${variant?: "primary" | "secondary" | "outline" | undefinedvariant}`,
      { 'button--disabled': disabled?: boolean | undefineddisabled },
    ]"
    ButtonHTMLAttributes.disabled?: Booleanish | undefined:ButtonHTMLAttributes.disabled?: Booleanish | undefineddisabled="disabled?: boolean | undefineddisabled"
    @onClick?: ((payload: MouseEvent) => void) | undefinedclick="$emit: (event: "click", event: MouseEvent) => void$emit('click', $event: MouseEvent$event)"
  >
    <default?(_: {}): anyslot></slot>
  </button: ButtonHTMLAttributes & ReservedPropsbutton>
</template>

<script setup lang="ts">
interface Props {
  Props.size?: "small" | "medium" | "large" | undefinedsize?: 'small' | 'medium' | 'large'
  Props.variant?: "primary" | "secondary" | "outline" | undefinedvariant?: 'primary' | 'secondary' | 'outline'
  Props.disabled?: boolean | undefineddisabled?: boolean
}

const defineProps: <Props>() => DefineProps<LooseRequired<Props>, "disabled"> (+2 overloads)
Vue `<script setup>` compiler macro for declaring component props. The expected argument is the same as the component `props` option. Example runtime declaration: ```js // using Array syntax const props = defineProps(['foo', 'bar']) // using Object syntax const props = defineProps({ foo: String, bar: { type: Number, required: true } }) ``` Equivalent type-based declaration: ```ts // will be compiled into equivalent runtime declarations const props = defineProps<{ foo?: string bar: number }>() ```
@see{@link https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits}This is only usable inside `<script setup>`, is compiled away in the output and should **not** be actually called at runtime.
defineProps
<Props>()
const defineEmits: <__VLS_Emit>() => __VLS_Emit (+2 overloads)
Vue `<script setup>` compiler macro for declaring a component's emitted events. The expected argument is the same as the component `emits` option. Example runtime declaration: ```js const emit = defineEmits(['change', 'update']) ``` Example type-based declaration: ```ts const emit = defineEmits<{ // <eventName>: <expected arguments> change: [] update: [value: number] // named tuple syntax }>() emit('change') emit('update', 1) ``` This is only usable inside `<script setup>`, is compiled away in the output and should **not** be actually called at runtime.
@see{@link https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits}
defineEmits
<{
(e: "click"e: 'click', event: MouseEventevent: MouseEvent): void }>() </script> <style scoped> .button { display: inline-flex; align-items: center; justify-content: center; /* Additional styling available in the GitHub repository */ } /* Size, variant, and state modifiers available in the GitHub repository */ </style>

Defining Stories for Testing

Create “stories” to showcase different button configurations:


const 
const buttonStories: {
    name: string;
    props: {
        variant: string;
        size: string;
    };
    slots: {
        default: string;
    };
}[]
buttonStories
= [
{ name: stringname: 'Primary Medium',
props: {
    variant: string;
    size: string;
}
props
: { variant: stringvariant: 'primary', size: stringsize: 'medium' },
slots: {
    default: string;
}
slots
: { default: stringdefault: 'Primary Button' },
}, { name: stringname: 'Secondary Medium',
props: {
    variant: string;
    size: string;
}
props
: { variant: stringvariant: 'secondary', size: stringsize: 'medium' },
slots: {
    default: string;
}
slots
: { default: stringdefault: 'Secondary Button' },
}, // and much more ... ]

Each story defines a name, props, and slot content.

Rendering Stories for Screenshots

Render all stories in one container to capture a comprehensive screenshot:

import type { type Component<Props = any, RawBindings = any, D = any, C extends ComputedOptions = ComputedOptions, M extends MethodOptions = MethodOptions, E extends EmitsOptions | Record<string, any[]> = {}, S extends Record<string, any> = any> = ConcreteComponent<Props, RawBindings, D, C, M, E, S> | ComponentPublicInstanceConstructor<Props, any, any, any, ComputedOptions, MethodOptions>
A type used in public APIs where a component type is expected. The constructor type is an artificial type returned by defineComponent().
Component
} from 'vue'
interface interface Story<T>Story<function (type parameter) T in Story<T>T> { Story<T>.name: stringname: string Story<T>.props: Record<string, any>props: type Record<K extends keyof any, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, any>
Story<T>.slots: Record<string, string>slots: type Record<K extends keyof any, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, string>
} function function renderStories<T>(component: Component, stories: Story<T>[]): HTMLElementrenderStories<function (type parameter) T in renderStories<T>(component: Component, stories: Story<T>[]): HTMLElementT>(component: Componentcomponent: type Component<Props = any, RawBindings = any, D = any, C extends ComputedOptions = ComputedOptions, M extends MethodOptions = MethodOptions, E extends EmitsOptions | Record<string, any[]> = {}, S extends Record<string, any> = any> = ConcreteComponent<Props, RawBindings, D, C, M, E, S> | ComponentPublicInstanceConstructor<Props, any, any, any, ComputedOptions, MethodOptions>
A type used in public APIs where a component type is expected. The constructor type is an artificial type returned by defineComponent().
Component
, stories: Story<T>[]stories: interface Story<T>Story<function (type parameter) T in renderStories<T>(component: Component, stories: Story<T>[]): HTMLElementT>[]): HTMLElement {
const const container: HTMLDivElementcontainer = var document: Document
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)
document
.Document.createElement<"div">(tagName: "div", options?: ElementCreationOptions): HTMLDivElement (+2 overloads)
Creates an instance of the element for the specified tag.
@paramtagName The name of an element. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElement)
createElement
('div')
const container: HTMLDivElementcontainer.ElementCSSInlineStyle.style: CSSStyleDeclaration
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)
style
.CSSStyleDeclaration.display: string
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/display)
display
= 'flex'
const container: HTMLDivElementcontainer.ElementCSSInlineStyle.style: CSSStyleDeclaration
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)
style
.CSSStyleDeclaration.flexDirection: string
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/flex-direction)
flexDirection
= 'column'
const container: HTMLDivElementcontainer.ElementCSSInlineStyle.style: CSSStyleDeclaration
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)
style
.CSSStyleDeclaration.gap: string
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/gap)
gap
= '16px'
const container: HTMLDivElementcontainer.ElementCSSInlineStyle.style: CSSStyleDeclaration
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)
style
.CSSStyleDeclaration.padding: string
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/padding)
padding
= '20px'
const container: HTMLDivElementcontainer.ElementCSSInlineStyle.style: CSSStyleDeclaration
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/style)
style
.CSSStyleDeclaration.backgroundColor: string
[MDN Reference](https://developer.mozilla.org/docs/Web/CSS/background-color)
backgroundColor
= '#ffffff'
stories: Story<T>[]stories.Array<Story<T>>.forEach(callbackfn: (value: Story<T>, index: number, array: Story<T>[]) => void, thisArg?: any): void
Performs the specified action for each element in an array.
@paramcallbackfn A function that accepts up to three arguments. forEach calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
forEach
((story: Story<T>story) => {
const const storyWrapper: HTMLDivElementstoryWrapper = var document: Document
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)
document
.Document.createElement<"div">(tagName: "div", options?: ElementCreationOptions): HTMLDivElement (+2 overloads)
Creates an instance of the element for the specified tag.
@paramtagName The name of an element. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElement)
createElement
('div')
const const label: HTMLHeadingElementlabel = var document: Document
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)
document
.Document.createElement<"h3">(tagName: "h3", options?: ElementCreationOptions): HTMLHeadingElement (+2 overloads)
Creates an instance of the element for the specified tag.
@paramtagName The name of an element. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElement)
createElement
('h3')
const label: HTMLHeadingElementlabel.Node.textContent: string | null
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent)
textContent
= story: Story<T>story.Story<T>.name: stringname
const storyWrapper: HTMLDivElementstoryWrapper.Node.appendChild<HTMLHeadingElement>(node: HTMLHeadingElement): HTMLHeadingElement
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/appendChild)
appendChild
(const label: HTMLHeadingElementlabel)
const { container: const storyContainer: anystoryContainer } = render(component: Componentcomponent, { props: Record<string, any>props: story: Story<T>story.Story<T>.props: Record<string, any>props, slots: Record<string, string>slots: story: Story<T>story.Story<T>.slots: Record<string, string>slots, }) const storyWrapper: HTMLDivElementstoryWrapper.Node.appendChild<any>(node: any): any
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/appendChild)
appendChild
(const storyContainer: anystoryContainer)
const container: HTMLDivElementcontainer.Node.appendChild<HTMLDivElement>(node: HTMLDivElement): HTMLDivElement
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/appendChild)
appendChild
(const storyWrapper: HTMLDivElementstoryWrapper)
}) return const container: HTMLDivElementcontainer }

Writing the Visual Regression Test

Write a test that renders the stories and captures a screenshot:



import { import describedescribe, import itit, import expectexpect } from 'vitest'
import import BaseButtonBaseButton from '../BaseButton.vue'
import { import renderrender } from 'vitest-browser-vue'
import { import pagepage } from '@vitest/browser/context'
import type { type Component<Props = any, RawBindings = any, D = any, C extends ComputedOptions = ComputedOptions, M extends MethodOptions = MethodOptions, E extends EmitsOptions | Record<string, any[]> = {}, S extends Record<string, any> = any> = ConcreteComponent<Props, RawBindings, D, C, M, E, S> | ComponentPublicInstanceConstructor<Props, any, any, any, ComputedOptions, MethodOptions>
A type used in public APIs where a component type is expected. The constructor type is an artificial type returned by defineComponent().
Component
} from 'vue'
// [buttonStories and renderStories defined above] import describedescribe('BaseButton', () => { import describedescribe('visual regression', () => { import itit('should match all button variants snapshot', async () => { const const container: anycontainer = renderStories(import BaseButtonBaseButton, buttonStories) var document: Document
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)
document
.Document.body: HTMLElement
Specifies the beginning and end of the document body. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/body)
body
.Node.appendChild<any>(node: any): any
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/appendChild)
appendChild
(const container: anycontainer)
const const screenshot: anyscreenshot = await import pagepage.screenshot({ path: stringpath: 'all-button-variants.png', }) // this assertion is acutaly not doing anything // but otherwise you would get a warning about the screenshot not being taken import expectexpect(const screenshot: anyscreenshot).toBeTruthy() var document: Document
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)
document
.Document.body: HTMLElement
Specifies the beginning and end of the document body. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/body)
body
.Node.removeChild<any>(child: any): any
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/removeChild)
removeChild
(const container: anycontainer)
}) }) })

Use render from vitest-browser-vue to capture components as they appear in a real browser.

💡 Note

Save this file with a .browser.spec.ts extension (e.g., BaseButton.browser.spec.ts) to match your browser test configuration.

Beyond Screenshots: Automated Comparison

Automate image comparison by encoding screenshots in base64 and comparing them against baseline snapshots:


// Helper function to take and compare screenshots
async function function takeAndCompareScreenshot(name: string, element: HTMLElement): Promise<void>takeAndCompareScreenshot(name: stringname: string, element: HTMLElementelement: HTMLElement) {
  const const screenshotDir: "./__screenshots__"screenshotDir = './__screenshots__'
  const const snapshotDir: "./__snapshots__"snapshotDir = './__snapshots__'
  const const screenshotPath: stringscreenshotPath = `${const screenshotDir: "./__screenshots__"screenshotDir}/${name: stringname}.png`

  // Append element to body
  var document: Document
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)
document
.Document.body: HTMLElement
Specifies the beginning and end of the document body. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/body)
body
.Node.appendChild<HTMLElement>(node: HTMLElement): HTMLElement
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/appendChild)
appendChild
(element: HTMLElementelement)
// Take screenshot const const screenshot: anyscreenshot = await page.screenshot({ path: stringpath: const screenshotPath: stringscreenshotPath, base64: booleanbase64: true, }) // Compare base64 snapshot await expect(const screenshot: anyscreenshot.base64).toMatchFileSnapshot(`${const snapshotDir: "./__snapshots__"snapshotDir}/${name: stringname}.snap`) // Save PNG for reference await expect(const screenshot: anyscreenshot.path).toBeTruthy() // Cleanup var document: Document
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)
document
.Document.body: HTMLElement
Specifies the beginning and end of the document body. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/body)
body
.Node.removeChild<HTMLElement>(child: HTMLElement): HTMLElement
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/removeChild)
removeChild
(element: HTMLElementelement)
}

Then update the test:

describe('BaseButton', () => {
  describe('visual regression', () => {
    it('should match all button variants snapshot', async () => {
      const const container: anycontainer = renderStories(BaseButton, buttonStories)
      await expect(
        takeAndCompareScreenshot('all-button-variants', const container: anycontainer)
      ).resolves.not.toThrow()
    })
  })
})

💡 Future improvements

Vitest is discussing native screenshot comparisons in browser mode. Follow and contribute at github.com/vitest-dev/vitest/discussions/690.

Match

Difference

Accept

Reject

Render Component

Capture Screenshot

Compare with Baseline

Test Passes

Review Changes

Update Baseline

Fix Component

Conclusion

Vitest’s experimental browser mode empowers developers to perform accurate visual regression testing of Vue components in real browser environments. While the current workflow requires manual review of screenshot comparisons, it establishes a foundation for more automated visual testing in the future. This approach also strengthens collaboration between developers and UI designers. Designers can review visual changes to components before production deployment by accessing the generated screenshots in the component library. For advanced visual testing capabilities, teams should explore dedicated tools like Playwright or Cypress that offer more features and maturity. Keep in mind to perform visual regression tests against your Base components.

Questions or thoughts?

Follow me on X for more TypeScript, Vue, and web dev insights! Feel free to DM me with:

  • Questions about this article
  • Topic suggestions
  • Feedback or improvements
Connect on X

Related Posts