[React] Building Component VRT + Accessibility Testing with Vitest

2026-03-08 hit count image

Sharing how to build VRT (Visual Regression Testing) and accessibility testing for React components using Vitest Browser Mode and Playwright. Covers consistent rendering with Docker and CI integration.

react

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 toMatchScreenshot out 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:

  1. Docker container provides a consistent environment (for local execution)
  2. Vitest launches a Playwright WebKit browser
  3. Each test renders a component and captures a screenshot
  4. Compares against existing baseline images; test fails if differences are found
  5. 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:

PackageRole
vitestTest runner (provides toMatchScreenshot for VRT)
@vitest/browserVitest Browser Mode plugin
@vitest/browser-playwrightPlaywright browser provider
@vitest/uiView test results in browser UI
@vitest/coverage-istanbulTest coverage reports
axe-coreAccessibility (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 like husky install to prevent Docker build failures.
  • Layer caching: Copies package.json and yarn.lock first 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_modules contains native binaries for that OS, which cannot be used in a Linux container. The anonymous volume hides the host’s node_modules and 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:

  1. Collects screenshot names referenced by toMatchScreenshot('filename') patterns from all *.vitest.tsx files
  2. Scans all PNG files in __screenshots__/ folders
  3. 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"
  }
}
ScriptPurpose
test:viewRun VRT locally with Docker (watch mode)
test:view:ciRun VRT in CI environment (directly without Docker)
test:view:updateUpdate baseline screenshots (when components are intentionally changed)
test:view:docker:buildBuild Docker image
test:view:cleanupDelete unused screenshot files
test:view:coverageGenerate 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 vrt label. Manual execution from the Actions tab is also available.
  • Same font installation: Like the Dockerfile, fonts-noto-core and fonts-noto-cjk are 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.

ItemDetails
Rendering/VRTVitest Browser Mode + toMatchScreenshot
Event testingSimulate interactions with @testing-library/user-event
Accessibility testingCheck WCAG violations with axe-core
Consistent renderingDocker + Noto fonts to eliminate environment differences
Test separation*.vitest.tsx file convention to separate from existing Jest tests
Screenshot managementStored in component directory’s __screenshots__/, cleanup script provided
CI integrationConditional 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

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS