TL;DR: Visual regression testing detects unintended UI changes by comparing screenshots. With Vitest’s experimental browser mode and Playwright, you can:
- Run tests in a real browser environment
- Define component stories for different states
- Capture screenshots and compare them with baseline images using snapshot testing
In this guide, you’ll learn how to set up visual regression testing for Vue components using Vitest.
Our test will generate this screenshot:

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 defineConfig
defineConfig } from 'vitest/config'
import import vue
vue from '@vitejs/plugin-vue'
export default import defineConfig
defineConfig({
plugins: any[]
plugins: [import vue
vue()],
})
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 defineWorkspace
defineWorkspace } from 'vitest/config'
export default import defineWorkspace
defineWorkspace([
{
extends: string
extends: './vitest.config.ts',
test: {
name: string;
include: string[];
exclude: string[];
environment: string;
}
test: {
name: string
name: 'unit',
include: string[]
include: ['**/*.spec.ts', '**/*.spec.tsx'],
exclude: string[]
exclude: ['**/*.browser.spec.ts', '**/*.browser.spec.tsx'],
environment: string
environment: 'jsdom',
},
},
{
extends: string
extends: './vitest.config.ts',
test: {
name: string;
include: string[];
browser: {
enabled: boolean;
provider: string;
headless: boolean;
instances: {
browser: string;
}[];
};
}
test: {
name: string
name: 'browser',
include: string[]
include: ['**/*.browser.spec.ts', '**/*.browser.spec.tsx'],
browser: {
enabled: boolean;
provider: string;
headless: boolean;
instances: {
browser: string;
}[];
}
browser: {
enabled: boolean
enabled: true,
provider: string
provider: 'playwright',
headless: boolean
headless: true,
instances: {
browser: string;
}[]
instances: [{ browser: string
browser: '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 & ReservedProps
button
HTMLAttributes.class?: any
:HTMLAttributes.class?: any
class="[
'button',
`button--${size?: "small" | "medium" | "large" | undefined
size}`,
`button--${variant?: "primary" | "secondary" | "outline" | undefined
variant}`,
{ 'button--disabled': disabled?: boolean | undefined
disabled },
]"
ButtonHTMLAttributes.disabled?: Booleanish | undefined
:ButtonHTMLAttributes.disabled?: Booleanish | undefined
disabled="disabled?: boolean | undefined
disabled"
@onClick?: ((payload: MouseEvent) => void) | undefined
click="$emit: (event: "click", event: MouseEvent) => void
$emit('click', $event: MouseEvent
$event)"
>
<default?(_: {}): any
slot></slot>
</button: ButtonHTMLAttributes & ReservedProps
button>
</template>
<script setup lang="ts">
interface Props {
Props.size?: "small" | "medium" | "large" | undefined
size?: 'small' | 'medium' | 'large'
Props.variant?: "primary" | "secondary" | "outline" | undefined
variant?: 'primary' | 'secondary' | 'outline'
Props.disabled?: boolean | undefined
disabled?: 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
}>()
```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.defineEmits<{
(e: "click"
e: 'click', event: MouseEvent
event: 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: string
name: 'Primary Medium',
props: {
variant: string;
size: string;
}
props: { variant: string
variant: 'primary', size: string
size: 'medium' },
slots: {
default: string;
}
slots: { default: string
default: 'Primary Button' },
},
{
name: string
name: 'Secondary Medium',
props: {
variant: string;
size: string;
}
props: { variant: string
variant: 'secondary', size: string
size: 'medium' },
slots: {
default: string;
}
slots: { default: string
default: '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: string
name: 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 TRecord<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 TRecord<string, string>
}
function function renderStories<T>(component: Component, stories: Story<T>[]): HTMLElement
renderStories<function (type parameter) T in renderStories<T>(component: Component, stories: Story<T>[]): HTMLElement
T>(component: Component
component: 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>[]): HTMLElement
T>[]): HTMLElement {
const const container: HTMLDivElement
container = 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.createElement('div')
const container: HTMLDivElement
container.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: HTMLDivElement
container.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: HTMLDivElement
container.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: HTMLDivElement
container.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: HTMLDivElement
container.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.forEach((story: Story<T>
story) => {
const const storyWrapper: HTMLDivElement
storyWrapper = 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.createElement('div')
const const label: HTMLHeadingElement
label = 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.createElement('h3')
const label: HTMLHeadingElement
label.Node.textContent: string | null
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent)textContent = story: Story<T>
story.Story<T>.name: string
name
const storyWrapper: HTMLDivElement
storyWrapper.Node.appendChild<HTMLHeadingElement>(node: HTMLHeadingElement): HTMLHeadingElement
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/appendChild)appendChild(const label: HTMLHeadingElement
label)
const { container: const storyContainer: any
storyContainer } = render(component: Component
component, {
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: HTMLDivElement
storyWrapper.Node.appendChild<any>(node: any): any
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/appendChild)appendChild(const storyContainer: any
storyContainer)
const container: HTMLDivElement
container.Node.appendChild<HTMLDivElement>(node: HTMLDivElement): HTMLDivElement
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/appendChild)appendChild(const storyWrapper: HTMLDivElement
storyWrapper)
})
return const container: HTMLDivElement
container
}
Writing the Visual Regression Test
Write a test that renders the stories and captures a screenshot:
import { import describe
describe, import it
it, import expect
expect } from 'vitest'
import import BaseButton
BaseButton from '../BaseButton.vue'
import { import render
render } from 'vitest-browser-vue'
import { import page
page } 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 describe
describe('BaseButton', () => {
import describe
describe('visual regression', () => {
import it
it('should match all button variants snapshot', async () => {
const const container: any
container = renderStories(import BaseButton
BaseButton, 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: any
container)
const const screenshot: any
screenshot = await import page
page.screenshot({
path: string
path: '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 expect
expect(const screenshot: any
screenshot).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: any
container)
})
})
})
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: string
name: string, element: HTMLElement
element: HTMLElement) {
const const screenshotDir: "./__screenshots__"
screenshotDir = './__screenshots__'
const const snapshotDir: "./__snapshots__"
snapshotDir = './__snapshots__'
const const screenshotPath: string
screenshotPath = `${const screenshotDir: "./__screenshots__"
screenshotDir}/${name: string
name}.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: HTMLElement
element)
// Take screenshot
const const screenshot: any
screenshot = await page.screenshot({
path: string
path: const screenshotPath: string
screenshotPath,
base64: boolean
base64: true,
})
// Compare base64 snapshot
await expect(const screenshot: any
screenshot.base64).toMatchFileSnapshot(`${const snapshotDir: "./__snapshots__"
snapshotDir}/${name: string
name}.snap`)
// Save PNG for reference
await expect(const screenshot: any
screenshot.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: HTMLElement
element)
}
Then update the test:
describe('BaseButton', () => {
describe('visual regression', () => {
it('should match all button variants snapshot', async () => {
const const container: any
container = renderStories(BaseButton, buttonStories)
await expect(
takeAndCompareScreenshot('all-button-variants', const container: any
container)
).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.
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.