Table of Contents
Overview
While reviewing testing strategies for shared components, we built a VRT (Visual Regression Testing) and accessibility testing environment using Vitest.
This article documents the build process of the Vitest-based testing environment that was ultimately chosen during the shared component testing strategy review. For the overall testing strategy and tool selection process, see [React] Testing Strategy for Shared Components in a Monorepo.
Why Vitest
After comparing Jest, Storybook, and Vitest during the testing strategy review, we chose Vitest.
- Native VRT support: Provides
toMatchScreenshotout of the box, enabling screenshot comparison without additional libraries. - Same tech stack as production code: Being Vite-based, it uses the same module resolution and CSS processing as the production build environment.
- Testing in a real browser: Through Browser Mode, components are rendered in a Playwright browser, making tests closer to the real environment than jsdom-based testing.
If you’re interested in comparisons with other tools, see [React] Building VRT with Jest + Puppeteer and [React] Building VRT + Accessibility Testing with Storybook Test Runner.
Overall Architecture
The workflow of Vitest-based VRT is as follows:
- Docker container provides a consistent environment (for local execution)
- Vitest launches a Playwright WebKit browser
- Each test renders a component and captures a screenshot
- Compares against existing baseline images; test fails if differences are found
- Accessibility tests check for WCAG violations using
axe-core
Docker Container (consistent fonts + browser environment)
→ Vitest (Browser Mode)
→ Playwright WebKit
→ Component rendering + screenshot capture
→ Baseline comparison with toMatchScreenshot
→ Accessibility check with axe-core
Environment Setup
The following configuration is needed to build a VRT + accessibility testing environment using Vitest Browser Mode and Playwright.
Package Installation
Install the required packages for Vitest Browser Mode and VRT/accessibility testing:
yarn add -D vitest @vitest/browser @vitest/browser-playwright @vitest/ui @vitest/coverage-istanbul axe-core
The role of each package is as follows:
| Package | Role |
|---|---|
vitest | Test runner (provides toMatchScreenshot for VRT) |
@vitest/browser | Vitest Browser Mode plugin |
@vitest/browser-playwright | Playwright browser provider |
@vitest/ui | View test results in browser UI |
@vitest/coverage-istanbul | Test coverage reports |
axe-core | Accessibility (a11y) checking engine |
Vitest Configuration
This is the core Vitest configuration file. It enables Browser Mode and sets up the screenshot storage path for VRT tests.
// vitest.config.ts
import react from '@vitejs/plugin-react';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
optimizeDeps: {
// Pre-bundle axe-core to improve test execution speed
include: ['axe-core'],
},
test: {
browser: {
enabled: true,
provider: playwright({
launchOptions: {
timeout: 60000,
},
}),
// Why WebKit: provides the most consistent rendering results
// across both CI (Linux) and local (macOS)
instances: [{ browser: 'webkit' }],
viewport: { width: 1280, height: 720 },
headless: true,
screenshotFailures: false,
expect: {
toMatchScreenshot: {
// Save screenshot files in __screenshots__ folder
// in the same directory as the test file
resolveScreenshotPath: ({ testFileDirectory, arg, ext }) => {
return `${testFileDirectory}/__screenshots__/${arg}${ext}`;
},
},
},
},
// Only include *.vitest.tsx files as test targets
include: ['**/*.vitest.tsx'],
setupFiles: ['./vitest-setup.ts'],
// Actually process CSS files including CSS Modules
css: true,
coverage: {
provider: 'istanbul',
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage',
},
},
css: {
modules: {
localsConvention: 'camelCase',
},
},
});
Key configuration points:
browser.instances: [{ browser: 'webkit' }]: Uses the WebKit browser. Unlike Chromium, it has fewer rendering differences across operating systems, providing consistent screenshots between CI and local environments.browser.expect.toMatchScreenshot.resolveScreenshotPath: Saves screenshot files in the__screenshots__folder in the same directory as the test file, making it easier to manage screenshots per component.include: ['**/*.vitest.tsx']: Separates existing Jest unit tests (*.test.tsx) from Vitest VRT tests (*.vitest.tsx), preventing each test runner from executing the other’s files.css: true: Actually processes CSS files so screenshots are captured with styles applied.
Setup File
This file handles common configuration before test execution.
// vitest-setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Import common styles (CSS variables, etc.)
// to test in the same style environment as production
import './src/utils/styles/common.scss';
// Clean up DOM after each test
afterEach(() => {
cleanup();
});
Importing @testing-library/jest-dom/vitest enables DOM matchers like toBeInTheDocument(). The reason for importing the common style file is that without CSS variables defined, components may render differently from production.
.gitignore Configuration
Temporary files generated during test execution should be excluded from Git. However, baseline screenshots (__screenshots__/) are commit targets.
# Vitest attachments (diff images generated on test failure, etc.)
.vitest-attachments/
Building a Consistent Rendering Environment with Docker
Why Docker Is Needed
VRT compares screenshots at the pixel level, so even slight differences in the rendering environment will cause test failures. The following factors in particular cause screenshot differences:
- Fonts: Default fonts differ between macOS and Linux, and even the same font can have subtle differences depending on the rendering engine.
- OS-specific text rendering: Anti-aliasing and sub-pixel rendering methods differ by OS.
Using Docker allows running tests with identical fonts and browsers in both local development (macOS or Windows) and CI (Linux) environments, preventing false positives caused by environment differences.
Dockerfile
Create a Dockerfile to build a consistent environment for Vitest VRT tests.
# Dockerfile
FROM node:24.13.0-bookworm
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# Timezone setting (to unify date/time display in screenshots)
ENV TZ=Asia/Tokyo
# Enable corepack for yarn
RUN corepack enable
# Install Playwright WebKit system dependencies
RUN npx playwright install-deps webkit
# Install fonts for deterministic rendering
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-core \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Root config files
COPY package.json yarn.lock .yarnrc.yml .nvmrc ./
# yarn plugins
COPY .yarn/plugins/ .yarn/plugins/
# Copy only workspace dependencies
COPY packages/lib/components/package.json packages/lib/components/
COPY packages/config/typescript/ packages/config/typescript/
COPY packages/config/stylelint/package.json packages/config/stylelint/
COPY packages/config/eslint/package.json packages/config/eslint/
# Install dependencies (Docker layer caching)
# --mode=skip-build: skip lifecycle scripts (prevents husky install failures, etc.)
RUN yarn install --mode=skip-build
# Install Playwright WebKit browser
RUN npx playwright install webkit
Key points:
fonts-noto-core,fonts-noto-cjk: Installs Noto fonts so all environments render with the same typeface. Including CJK fonts ensures consistent testing for components with multilingual text.yarn install --mode=skip-build: Skips build scripts likehusky installto prevent Docker build failures.- Layer caching: Copies
package.jsonandyarn.lockfirst for installation, then mounts source code, so the cache is reused when dependencies haven’t changed.
docker-compose.yml
Create a docker-compose.yml to run Vitest VRT tests in a Docker container.
# docker-compose.yml
services:
vrt:
build:
# Set monorepo root as build context (to include all workspace package.json files)
context: ../../..
# Dockerfile path (relative to build context)
dockerfile: packages/lib/components/Dockerfile
image: vitest-vrt
volumes:
# Mount source code (test files, screenshots, etc.)
- ./:/app/packages/lib/components
# Hide host OS node_modules and use Linux binaries from the image (anonymous volume)
- /app/packages/lib/components/node_modules
environment:
# Use polling for file change detection (for Docker mount environments)
- CHOKIDAR_USEPOLLING=true
working_dir: /app/packages/lib/components
Key configuration points:
- Anonymous volume (
/app/.../node_modules): The host (macOS, Windows)node_modulescontains native binaries for that OS, which cannot be used in a Linux container. The anonymous volume hides the host’snode_modulesand uses the Linux binaries installed during Docker image build. - Source code mount: Only source code is mounted so code changes are reflected in real-time. In watch mode, modifying files automatically re-runs tests.
Writing Test Code
With the test environment set up, let’s write actual test code.
File Convention
VRT test files use the *.vitest.tsx filename convention.
ComponentName/
├── index.tsx # Component implementation
├── index.test.tsx # Unit tests (Jest)
├── index.vitest.tsx # VRT + event + accessibility tests (Vitest)
└── __screenshots__/ # Baseline screenshots (auto-generated)
├── button-children-text.png
├── button-variants.png
└── ...
We maintained existing Jest unit tests (*.test.tsx) while separating VRT and event/accessibility tests into *.vitest.tsx files. While it’s possible to consolidate all tests into Vitest in the future, we structured it this way to clearly distinguish between VRT tests and unit tests for now.
Rendering Tests (Verifying View with Props Changes)
Capture screenshots for each Props combination to verify the View.
// src/components/Button/Button/index.vitest.tsx
import { render } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { page } from 'vitest/browser';
import { Button } from '.';
describe('Button display verification', () => {
test('renders with text content', async () => {
render(<Button>Click me</Button>);
await expect
.element(page.getByRole('button'))
.toMatchScreenshot('button-children-text.png');
});
test('renders all variants', async () => {
render(
<div
data-testid="button-variants"
style={{ display: 'inline-flex', gap: '8px' }}
>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="tertiary">Tertiary</Button>
<Button variant="destructive">Destructive</Button>
</div>
);
await expect
.element(page.getByTestId('button-variants'))
.toMatchScreenshot('button-variants.png');
});
test('disabled', async () => {
render(
<div
data-testid="button-disabled"
style={{ display: 'inline-flex', gap: '8px' }}
>
<Button disabled>Disabled</Button>
<Button variant="primary" disabled>
Primary Disabled
</Button>
<Button variant="secondary" disabled>
Secondary Disabled
</Button>
</div>
);
await expect
.element(page.getByTestId('button-disabled'))
.toMatchScreenshot('button-disabled.png');
});
});
Comparing multiple variants in a single screenshot reduces the number of tests while still verifying all states.
Common Test Cases
Beyond Props changes, test edge cases that frequently occur in real usage:
// Long text: verify text-overflow etc. works correctly
test('with long text content', async () => {
render(
<div
data-testid="button-long-text"
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
width: '300px',
}}
>
<Button>This is a button with very long text content</Button>
<Button widthType="small">
This is a button with very long text content
</Button>
</div>
);
await expect
.element(page.getByTestId('button-long-text'))
.toMatchScreenshot('button-long-text.png');
});
// Narrow parent: verify layout doesn't break when container is narrow
test('with narrow parent element', async () => {
render(
<div
data-testid="button-narrow-parent"
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
width: '80px',
}}
>
<Button>Button Label</Button>
<Button widthType="full">Button Label</Button>
</div>
);
await expect
.element(page.getByTestId('button-narrow-parent'))
.toMatchScreenshot('button-narrow-parent.png');
});
Event Tests
Simulate user interactions to verify event handlers work correctly.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
describe('Event', () => {
test('calls onClick handler when clicked', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
test('does not call onClick when disabled', async () => {
const onClick = vi.fn();
render(
<Button onClick={onClick} disabled>
Click me
</Button>
);
await userEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
Accessibility Tests
Use axe-core to automatically check for WCAG accessibility violations.
import { runAxe } from '../../../test-utils/axe';
describe('Accessibility', () => {
test('should have no accessibility violations', async () => {
const { container } = render(<Button>Accessible Button</Button>);
const results = await runAxe(container);
expect(results.violations).toHaveLength(0);
});
test('should have no accessibility violations when disabled', async () => {
const { container } = render(<Button disabled>Disabled Button</Button>);
const results = await runAxe(container);
expect(results.violations).toHaveLength(0);
});
test('should have no accessibility violations for all variants', async () => {
const { container } = render(
<div>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="tertiary">Tertiary</Button>
<Button variant="destructive">Destructive</Button>
</div>
);
const results = await runAxe(container);
expect(results.violations).toHaveLength(0);
});
});
Accessibility Test Utility
A common axe-core wrapper utility used across all tests, sharing default configuration to reduce test code duplication.
// src/test-utils/axe.ts
import axe from 'axe-core';
/**
* Default axe-core configuration
*
* Rules commonly disabled across all tests:
* - color-contrast: false positives from test environment background color
* - button-name: icon-only buttons are tested separately with aria-label
*/
export const defaultAxeConfig: axe.RunOptions = {
rules: {
'color-contrast': { enabled: false },
'button-name': { enabled: false },
},
};
/**
* Run axe-core to check for accessibility violations
*/
export async function runAxe(
container: Element,
config?: axe.RunOptions
): Promise<axe.AxeResults> {
return axe.run(container, {
...defaultAxeConfig,
...config,
rules: {
...defaultAxeConfig.rules,
...config?.rules,
},
});
}
Screenshot Cleanup Script
When components are deleted or tests are modified, unused screenshot files may remain. This script automatically cleans up such unnecessary files.
// tool/cleanup-vitest-screenshots.mjs
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SRC_DIR = path.resolve(__dirname, '../src')
// Collect screenshot names referenced by toMatchScreenshot('filename')
// from *.vitest.tsx files
function findUsedScreenshots() {
const testFiles = findFilesRecursively(SRC_DIR, /\.vitest\.tsx$/)
const usedScreenshots = new Set()
for (const testFile of testFiles) {
const content = fs.readFileSync(testFile, 'utf-8')
const matches = content.matchAll(/toMatchScreenshot\(['"]([^'"]*)['"]\)/g)
for (const match of matches) {
const name = match[1].replace(/\.png$/, '')
usedScreenshots.add(name)
}
}
return usedScreenshots
}
// Collect all PNG files from __screenshots__ folders
function findScreenshotFiles() { /* ... */ }
function main() {
const usedScreenshots = findUsedScreenshots()
const screenshotFiles = findScreenshotFiles()
let deletedCount = 0
for (const file of screenshotFiles) {
const basename = extractBasename(file)
if (!usedScreenshots.has(basename)) {
fs.unlinkSync(file)
deletedCount++
console.log(`Deleted: ${file}`)
}
}
console.log(`Used screenshots: ${usedScreenshots.size}`)
console.log(`Deleted files: ${deletedCount}`)
}
main()
How it works:
- Collects screenshot names referenced by
toMatchScreenshot('filename')patterns from all*.vitest.tsxfiles - Scans all PNG files in
__screenshots__/folders - Deletes unreferenced files
Run Scripts
Add the following scripts to package.json to run the tests.
// package.json
{
"scripts": {
"test:view": "yarn test:view:docker:build && docker compose run --rm -it vrt npx vitest",
"test:view:ci": "TZ=Asia/Tokyo vitest run",
"test:view:update": "yarn test:view:docker:build && docker compose run --rm vrt npx vitest run --update",
"test:view:docker:build": "docker compose build vrt",
"test:view:cleanup": "node ./tool/cleanup-vitest-screenshots.mjs",
"test:view:coverage": "yarn test:view:docker:build && docker compose run --rm vrt npx vitest run --coverage"
}
}
| Script | Purpose |
|---|---|
test:view | Run VRT locally with Docker (watch mode) |
test:view:ci | Run VRT in CI environment (directly without Docker) |
test:view:update | Update baseline screenshots (when components are intentionally changed) |
test:view:docker:build | Build Docker image |
test:view:cleanup | Delete unused screenshot files |
test:view:coverage | Generate test coverage report |
Locally, tests run through Docker for a consistent environment, while in CI they run directly within the container. The reason test:view:ci sets TZ=Asia/Tokyo is to prevent screenshots of components that display dates/times from varying by timezone.
CI Integration (GitHub Actions)
Workflow configuration for automatically running VRT in GitHub Actions.
# .github/workflows/check_code_components.yml (excerpt)
test-components-vrt:
# Manual execution via workflow_dispatch, or auto-run on PRs with 'vrt' label
if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'vrt')
name: Test components VRT
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: node:24.13.0-bookworm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Enable corepack
run: corepack enable
- name: Install fonts for deterministic rendering
run: |
apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-core \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
- name: Install dependencies
run: yarn install --immutable
- name: Get Playwright version
id: playwright-version
run: echo "version=$(yarn info @vitest/browser-playwright --json | jq -r '.children.Version')" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-webkit-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: yarn playwright install webkit --with-deps
- name: Install Playwright dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: yarn playwright install-deps webkit
- name: Test view
run: yarn test:view:ci:components
Key points of this CI workflow:
- Conditional execution: Running VRT on every PR takes too long, so it’s configured to auto-run only on PRs with the
vrtlabel. Manual execution from the Actions tab is also available. - Same font installation: Like the Dockerfile,
fonts-noto-coreandfonts-noto-cjkare installed to ensure the same rendering as the local Docker environment. - Playwright browser caching: Since Playwright browser installation takes time, it’s cached per version to reduce CI execution time.
Directory Structure
The complete directory structure of files created or modified in this article:
Project Root/
├── .github/
│ └── workflows/
│ └── check_code_components.yml # CI workflow (modified)
├── .gitignore # Added .vitest-attachments/ (modified)
├── packages/
│ └── lib/
│ └── components/
│ ├── Dockerfile # Docker image config (new)
│ ├── docker-compose.yml # Docker Compose config (new)
│ ├── package.json # Added run scripts (modified)
│ ├── vitest.config.ts # Vitest config (new)
│ ├── vitest-setup.ts # Test setup (new)
│ ├── tool/
│ │ └── cleanup-vitest-screenshots.mjs # Cleanup script (new)
│ └── src/
│ ├── test-utils/
│ │ └── axe.ts # Accessibility test utility (new)
│ └── components/
│ └── Button/
│ └── Button/
│ ├── index.tsx
│ ├── index.test.tsx
│ ├── index.vitest.tsx # VRT test (new)
│ └── __screenshots__/ # Baseline screenshots (auto-generated)
│ ├── button-children-text.png
│ ├── button-variants.png
│ └── ...
Summary
This article introduced how to build a VRT + accessibility testing environment using Vitest.
| Item | Details |
|---|---|
| Rendering/VRT | Vitest Browser Mode + toMatchScreenshot |
| Event testing | Simulate interactions with @testing-library/user-event |
| Accessibility testing | Check WCAG violations with axe-core |
| Consistent rendering | Docker + Noto fonts to eliminate environment differences |
| Test separation | *.vitest.tsx file convention to separate from existing Jest tests |
| Screenshot management | Stored in component directory’s __screenshots__/, cleanup script provided |
| CI integration | Conditional execution with vrt label in GitHub Actions |
The advantage of the Vitest-based approach is that it uses the same Vite build pipeline as production code, making CSS Modules and SCSS processing natural, and the toMatchScreenshot API is concise, resulting in highly readable test code.
For the testing strategy review process and comparisons with other tools, see [React] Testing Strategy for Shared Components in a Monorepo. For how to gradually introduce Vitest to an existing Jest project, check out [React] Introducing Vitest to a Jest Project - View Test Separation Strategy.
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.