[React] Vitest로 컴포넌트 VRT + 접근성 테스트 환경 구축

2026-03-08 hit count image

Vitest Browser Mode와 Playwright를 사용하여 React 컴포넌트의 VRT(Visual Regression Testing)와 접근성 테스트 환경을 구축하는 방법을 공유합니다. Docker를 활용한 일관된 렌더링 환경과 CI 통합까지 다룹니다.

react

개요

공통 컴포넌트의 테스트 방침을 검토하면서, Vitest를 사용한 VRT(Visual Regression Testing)와 접근성 테스트 환경을 구축했습니다.

이 글은 공통 컴포넌트의 테스트 방침 수립 과정에서 최종 선택된 Vitest 기반 테스트 환경의 구축 과정을 정리한 것입니다. 전체 테스트 방침과 도구 선정 과정은 [React] 모노레포 환경에서의 공통 컴포넌트 테스트 방침을 참고해주세요.

왜 Vitest인가

테스트 방침 검토에서 Jest, Storybook, Vitest 세 가지를 비교한 결과, Vitest를 선택했습니다.

  • 네이티브 VRT 지원: toMatchScreenshot를 기본 제공하여 별도 라이브러리 없이 스크린샷 비교가 가능합니다.
  • 프로덕트 코드와 동일한 기술 스택: Vite 기반이므로 프로덕트 빌드 환경과 동일한 모듈 해석, CSS 처리를 사용합니다.
  • 실제 브라우저에서 테스트: Browser Mode를 통해 Playwright 브라우저에서 컴포넌트를 렌더링하므로, jsdom 기반 테스트보다 실제 환경에 가깝습니다.

다른 도구와의 비교가 궁금하다면, [React] Jest + Puppeteer로 VRT 환경 구축[React] Storybook Test Runner로 VRT + 접근성 테스트 구축도 참고해주세요.

전체 구조

Vitest 기반 VRT의 동작 흐름은 다음과 같습니다.

  1. Docker 컨테이너에서 일관된 환경을 제공 (로컬 실행 시)
  2. Vitest가 Playwright WebKit 브라우저를 실행
  3. 각 테스트에서 컴포넌트를 렌더링하고 스크린샷을 캡처
  4. 기존 베이스라인 이미지와 비교하여 차이가 있으면 테스트 실패
  5. 접근성 테스트는 axe-core로 WCAG 위반 사항을 검사
Docker Container (일관된 폰트 + 브라우저 환경)
  → Vitest (Browser Mode)
    → Playwright WebKit
      → 컴포넌트 렌더링 + 스크린샷 캡처
      → toMatchScreenshot으로 베이스라인 비교
      → axe-core로 접근성 검사

환경 설정

Vitest Browser Mode와 Playwright를 사용하여 VRT + 접근성 테스트 환경을 구축하기 위해서 다음과 같은 설정이 필요합니다.

패키지 설치

Vitest Browser Mode와 VRT/접근성 테스트에 필요한 패키지를 설치합니다.

yarn add -D vitest @vitest/browser @vitest/browser-playwright @vitest/ui @vitest/coverage-istanbul axe-core

각 패키지의 역할은 다음과 같습니다.

패키지역할
vitest테스트 러너 (VRT의 toMatchScreenshot 기본 제공)
@vitest/browserVitest Browser Mode 플러그인
@vitest/browser-playwrightPlaywright 브라우저 프로바이더
@vitest/ui테스트 결과를 브라우저 UI로 확인
@vitest/coverage-istanbul테스트 커버리지 리포트
axe-core접근성(a11y) 검사 엔진

Vitest 설정

다음은 Vitest의 핵심 설정 파일입니다. Browser Mode를 활성화하고, 스크린샷 저장 경로를 설정하여 VRT 테스트를 구성합니다.

// 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: {
    // axe-core를 사전 번들링하여 테스트 실행 속도 향상
    include: ['axe-core'],
  },
  test: {
    browser: {
      enabled: true,
      provider: playwright({
        launchOptions: {
          timeout: 60000,
        },
      }),
      // WebKit을 사용하는 이유: CI(Linux)와 로컬(macOS) 모두에서
      // 가장 일관된 렌더링 결과를 제공
      instances: [{ browser: 'webkit' }],
      viewport: { width: 1280, height: 720 },
      headless: true,
      screenshotFailures: false,
      expect: {
        toMatchScreenshot: {
          // 스크린샷 파일을 테스트 파일과 같은 디렉토리의
          // __screenshots__ 폴더에 저장
          resolveScreenshotPath: ({ testFileDirectory, arg, ext }) => {
            return `${testFileDirectory}/__screenshots__/${arg}${ext}`;
          },
        },
      },
    },
    // *.vitest.tsx 파일만 테스트 대상으로 포함
    include: ['**/*.vitest.tsx'],
    setupFiles: ['./vitest-setup.ts'],
    // CSS Modules 등 스타일 파일을 실제로 처리
    css: true,
    coverage: {
      provider: 'istanbul',
      reporter: ['text', 'html', 'lcov'],
      reportsDirectory: './coverage',
    },
  },
  css: {
    modules: {
      localsConvention: 'camelCase',
    },
  },
});

주요 설정 포인트는 다음과 같습니다.

  • browser.instances: [{ browser: 'webkit' }]: WebKit 브라우저를 사용합니다. Chromium과 달리 OS 간 렌더링 차이가 적어 CI와 로컬 환경에서 일관된 스크린샷을 얻을 수 있습니다.
  • browser.expect.toMatchScreenshot.resolveScreenshotPath: 스크린샷 파일을 테스트 파일과 같은 디렉토리의 __screenshots__ 폴더에 저장합니다. 컴포넌트별로 스크린샷을 관리하기 쉬워집니다.
  • include: ['**/_.vitest.tsx']: 기존 Jest 단위 테스트(_.test.tsx)와 Vitest VRT 테스트(\*.vitest.tsx)를 분리합니다. 두 테스트 러너가 서로의 파일을 실행하지 않도록 합니다.
  • css: true: CSS 파일을 실제로 처리하여 스타일이 적용된 상태에서 스크린샷을 캡처합니다.

Setup 파일

테스트 실행 전 공통 설정을 담당하는 파일입니다.

// vitest-setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// 공통 스타일(CSS 변수 등)을 import하여
// 프로덕트와 동일한 스타일 환경에서 테스트
import './src/utils/styles/common.scss';

// 각 테스트 후 DOM 정리
afterEach(() => {
  cleanup();
});

@testing-library/jest-dom/vitest를 import하면 toBeInTheDocument() 등의 DOM matcher를 사용할 수 있습니다. 여기서 공통 스타일 파일을 import하는 이유는 CSS 변수 등이 정의되지 않으면 컴포넌트가 프로덕트와 다르게 렌더링될 수 있기 때문입니다.

.gitignore 설정

테스트 실행 중 생성되는 임시 파일은 Git에서 제외시킬 필요가 있습니다. 다만, VRT의 비교 기준이 되는 스크린샷(__screenshots__/)은 커밋 대상이 됩니다.

# Vitest 첨부 파일 (테스트 실패 시 생성되는 diff 이미지 등)
.vitest-attachments/

Docker로 일관된 렌더링 환경 구축

VRT는 픽셀 단위로 스크린샷을 비교하기 때문에, 렌더링 환경이 조금이라도 다르면 테스트가 실패합니다. 특히 다음 요인들이 스크린샷 차이를 유발합니다:

  • 폰트: macOS와 Linux에서 기본 폰트가 다르고, 같은 폰트라도 렌더링 엔진에 따라 미세한 차이가 있습니다.
  • OS별 텍스트 렌더링: 안티앨리어싱, 서브픽셀 렌더링 방식이 OS마다 다릅니다.

Docker를 사용하면 로컬 개발 환경(macOS 또는 Windows)과 CI 환경(Linux) 모두에서 동일한 폰트와 브라우저로 테스트를 실행할 수 있어, 환경 차이로 인한 거짓 실패(false positive)를 방지합니다.

Dockerfile

다음과 같이 Dockerfile을 작성하여 Vitest VRT 테스트를 위한 일관된 환경을 구축합니다.

# Dockerfile
FROM node:24.13.0-bookworm

ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# 타임존 설정 (스크린샷의 날짜/시간 표시를 통일)
ENV TZ=Asia/Tokyo

# yarn 사용을 위해 corepack 활성화
RUN corepack enable

# Playwright WebKit의 시스템 의존 패키지 설치
RUN npx playwright install-deps webkit

# 결정론적 렌더링을 위한 폰트 설치
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

# 루트 설정 파일
COPY package.json yarn.lock .yarnrc.yml .nvmrc ./

# yarn 플러그인
COPY .yarn/plugins/ .yarn/plugins/

# workspace 의존성만 복사
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/

# 의존성 설치 (Docker 레이어 캐싱)
# --mode=skip-build: 라이프사이클 스크립트를 건너뜀 (husky install 등의 실패 방지)
RUN yarn install --mode=skip-build

# Playwright WebKit 브라우저 설치
RUN npx playwright install webkit

여기서 주요 설정 포인트는 다음과 같습니다.

  • fonts-noto-core, fonts-noto-cjk: Noto 폰트를 설치하여 모든 환경에서 동일한 글꼴로 렌더링합니다. CJK(한중일) 폰트도 포함하여 다국어 텍스트가 포함된 컴포넌트도 일관되게 테스트할 수 있습니다.
  • yarn install --mode=skip-build: husky install 등 빌드 스크립트를 건너뛰어 Docker 빌드 실패를 방지합니다.
  • 레이어 캐싱: package.jsonyarn.lock을 먼저 복사하고 설치한 뒤 소스 코드를 마운트하여, 의존성이 변경되지 않으면 캐시를 재사용합니다.

docker-compose.yml

다음과 같이 docker-compose.yml을 작성하여 Docker 컨테이너에서 Vitest VRT 테스트를 실행할 수 있도록 합니다.

# docker-compose.yml
services:
  vrt:
    build:
      # 모노레포 루트를 빌드 컨텍스트로 지정 (모든 workspace의 package.json을 포함하기 위해)
      context: ../../..
      # 빌드에 사용할 Dockerfile 경로 (빌드 컨텍스트 기준 상대 경로)
      dockerfile: packages/lib/components/Dockerfile
    image: vitest-vrt
    volumes:
      # 소스 코드(테스트 파일, 스크린샷 등)를 마운트
      - ./:/app/packages/lib/components
      # 호스트 OS용 node_modules를 숨기고, 이미지의 Linux용 바이너리를 사용 (익명 볼륨)
      - /app/packages/lib/components/node_modules
    environment:
      # 파일 변경 감시에 폴링 사용 (Docker 마운트 환경에서의 감지용)
      - CHOKIDAR_USEPOLLING=true
    working_dir: /app/packages/lib/components

여기서 주요 설정 포인트는 다음과 같습니다.

  • 익명 볼륨 (/app/.../node_modules): 호스트(macOS, Windows)의 node_modules에는 해당 OS용 네이티브 바이너리가 포함되어 있어 Linux 컨테이너에서 사용할 수 없습니다. 익명 볼륨으로 호스트의 node_modules를 숨기고, Docker 이미지 빌드 시 설치된 Linux용 바이너리를 사용할 수 있도록 합니다.
  • 소스 코드 마운트: 소스 코드만 마운트하여 코드 변경이 실시간으로 반영됩니다. watch 모드에서 파일을 수정하면 자동으로 테스트가 재실행됩니다.

테스트 코드 작성

테스트 환경이 구축되었으니, 이제 실제 테스트 코드를 작성해 보겠습니다.

파일 컨벤션

VRT 테스트 파일은 *.vitest.tsx 파일명 컨벤션을 사용합니다.

ComponentName/
├── index.tsx                # 컴포넌트 구현
├── index.test.tsx           # 단위 테스트 (Jest)
├── index.vitest.tsx         # VRT + 이벤트 + 접근성 테스트 (Vitest)
└── __screenshots__/         # 베이스라인 스크린샷 (자동 생성)
    ├── button-children-text.png
    ├── button-variants.png
    └── ...

기존의 Jest로 작성된 단위 테스트(*.test.tsx)를 유지하면서, VRT와 이벤트/접근성 테스트는 *.vitest.tsx 파일로 분리하여 작성했습니다. 추후에 모든 테스트를 Vitest로 통합할 수도 있지만, 현재는 VRT 테스트와 단위 테스트를 명확히 구분하여 관리하기 위해 이렇게 구성했습니다.

렌더링 테스트 (Props 변경에 따른 View 확인)

각 Props 조합별로 스크린샷을 캡처하여 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 표시 확인', () => {
  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');
  });
});

여러 variant를 하나의 스크린샷에 모아 비교하면 테스트 수를 줄이면서도 모든 상태를 검증할 수 있습니다.

자주 사용하는 테스트 케이스

Props 변경 외에도, 실제 사용 환경에서 자주 발생하는 엣지 케이스를 테스트합니다.

// 긴 텍스트: text-overflow 등이 제대로 동작하는지 확인
test('긴 텍스트가 포함된 경우', async () => {
  render(
    <div
      data-testid="button-long-text"
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: '8px',
        width: '300px',
      }}
    >
      <Button>이것은 매우 긴 텍스트가 포함된 버튼입니다</Button>
      <Button widthType="small">
        이것은 매우 긴 텍스트가 포함된 버튼입니다
      </Button>
    </div>
  );
  await expect
    .element(page.getByTestId('button-long-text'))
    .toMatchScreenshot('button-long-text.png');
});

// 좁은 부모 요소: 컨테이너가 좁을 때 레이아웃이 깨지지 않는지 확인
test('부모 요소의 폭이 좁은 경우', async () => {
  render(
    <div
      data-testid="button-narrow-parent"
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: '8px',
        width: '80px',
      }}
    >
      <Button>버튼 라벨</Button>
      <Button widthType="full">버튼 라벨</Button>
    </div>
  );
  await expect
    .element(page.getByTestId('button-narrow-parent'))
    .toMatchScreenshot('button-narrow-parent.png');
});

이벤트 테스트

사용자 인터랙션을 시뮬레이션하여 이벤트 핸들러가 올바르게 동작하는지 검증합니다.

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();
  });
});

접근성 테스트

axe-core를 사용하여 WCAG 접근성 위반 사항을 자동으로 검사합니다.

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);
  });
});

접근성 테스트 유틸리티

모든 테스트에서 공통적으로 사용하는 axe-core 래퍼 유틸리티를 만들어서, 기본 설정을 공유하여 테스트 코드의 중복을 줄였습니다.

// src/test-utils/axe.ts
import axe from 'axe-core';

/**
 * 기본 axe-core 설정
 *
 * 전체 테스트에서 공통으로 비활성화하는 규칙:
 * - color-contrast: 테스트 환경의 배경색과 조합으로 오탐이 발생
 * - button-name: 아이콘 전용 버튼은 별도 aria-label로 테스트
 */
export const defaultAxeConfig: axe.RunOptions = {
  rules: {
    'color-contrast': { enabled: false },
    'button-name': { enabled: false },
  },
};

/**
 * axe-core를 실행하여 접근성 위반 사항을 검사
 */
export async function runAxe(
  container: Element,
  config?: axe.RunOptions
): Promise<axe.AxeResults> {
  return axe.run(container, {
    ...defaultAxeConfig,
    ...config,
    rules: {
      ...defaultAxeConfig.rules,
      ...config?.rules,
    },
  });
}

스크린샷 클린업 스크립트

컴포넌트를 삭제하거나 테스트를 수정하면 더 이상 사용되지 않는 스크린샷 파일이 남을 수 있습니다. 이런 불필요한 파일을 자동으로 정리하기 위해 다음과 같은 스크립트를 작성했습니다.

// 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')

// *.vitest.tsx 파일에서 toMatchScreenshot('파일명')으로 참조되는
// 스크린샷 이름을 수집
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
}

// __screenshots__ 폴더의 모든 PNG 파일을 수집
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()

이 스크립트의 동작 원리는 다음과 같습니다.

  1. 모든 *.vitest.tsx 파일에서 toMatchScreenshot('파일명') 패턴으로 참조되는 스크린샷 이름을 수집
  2. __screenshots__/ 폴더의 모든 PNG 파일을 탐색
  3. 참조되지 않는 파일을 삭제

실행 스크립트

이렇게 작성된 테스트를 실행하기 위해, 다음과 같은 스크립트를 package.json에 추가합니다.

// 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"
  }
}

각각의 스크립트는 다음과 같은 역할을 합니다.

스크립트용도
test:view로컬에서 Docker로 VRT 실행 (watch 모드)
test:view:ciCI 환경에서 VRT 실행 (Docker 없이 직접 실행)
test:view:update베이스라인 스크린샷 갱신 (컴포넌트가 의도적으로 변경된 경우)
test:view:docker:buildDocker 이미지 빌드
test:view:cleanup사용하지 않는 스크린샷 파일 삭제
test:view:coverage테스트 커버리지 리포트 생성

로컬에서는 Docker를 통해 일관된 환경에서 테스트하고, CI에서는 컨테이너 내에서 직접 실행합니다. test:view:ci에서 TZ=Asia/Tokyo를 설정하는 이유는 날짜/시간을 표시하는 컴포넌트의 스크린샷이 타임존에 따라 달라지는 것을 방지하기 위해서입니다.

CI 연동 (GitHub Actions)

GitHub Actions에서 VRT를 자동으로 실행하는 워크플로우 설정입니다.

# .github/workflows/check_code_components.yml (일부 발췌)
test-components-vrt:
  # workflow_dispatch로 수동 실행하거나, 'vrt' 라벨이 붙은 PR에서 자동 실행
  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

이 CI 워크플로우의 핵심 포인트는 다음과 같습니다.

  • 조건부 실행: 모든 PR에서 VRT를 실행하면 시간이 오래 걸리므로, vrt 라벨이 붙은 PR에서만 자동 실행되도록 설정합니다. 필요시 Actions 탭에서 수동 실행도 가능합니다.
  • 동일한 폰트 설치: Dockerfile과 마찬가지로 fonts-noto-core, fonts-noto-cjk를 설치하여 로컬 Docker 환경과 동일한 렌더링을 보장합니다.
  • Playwright 브라우저 캐싱: Playwright 브라우저 설치에 시간이 걸리므로, 버전별로 캐싱하여 CI 실행 시간을 단축합니다.

디렉토리 구조

이 글에서 생성하거나 수정하는 파일들의 전체 디렉토리 구조는 다음과 같습니다.

프로젝트 루트/
├── .github/
│   └── workflows/
│       └── check_code_components.yml  # CI 워크플로우 (수정)
├── .gitignore                         # .vitest-attachments/ 추가 (수정)
├── packages/
│   └── lib/
│       └── components/
│           ├── Dockerfile             # Docker 이미지 설정 (신규)
│           ├── docker-compose.yml     # Docker Compose 설정 (신규)
│           ├── package.json           # 실행 스크립트 추가 (수정)
│           ├── vitest.config.ts       # Vitest 설정 (신규)
│           ├── vitest-setup.ts        # 테스트 셋업 (신규)
│           ├── tool/
│           │   └── cleanup-vitest-screenshots.mjs  # 클린업 스크립트 (신규)
│           └── src/
│               ├── test-utils/
│               │   └── axe.ts         # 접근성 테스트 유틸리티 (신규)
│               └── components/
│                   └── Button/
│                       └── Button/
│                           ├── index.tsx
│                           ├── index.test.tsx
│                           ├── index.vitest.tsx       # VRT 테스트 (신규)
│                           └── __screenshots__/       # 베이스라인 스크린샷 (자동 생성)
│                               ├── button-children-text.png
│                               ├── button-variants.png
│                               └── ...

정리

이 글에서는 Vitest를 사용한 VRT + 접근성 테스트 환경 구축 방법을 소개했습니다.

항목내용
렌더링/VRTVitest Browser Mode + toMatchScreenshot
이벤트 테스트@testing-library/user-event로 인터랙션 시뮬레이션
접근성 테스트axe-core로 WCAG 위반 사항 검사
일관된 렌더링Docker + Noto 폰트로 환경 차이 해소
테스트 분리*.vitest.tsx 파일 컨벤션으로 기존 Jest 테스트와 분리
스크린샷 관리컴포넌트 디렉토리의 __screenshots__/에 저장, 클린업 스크립트 제공
CI 연동GitHub Actions에서 vrt 라벨로 조건부 실행

Vitest 기반 접근 방식의 장점은 프로덕트 코드와 동일한 Vite 빌드 파이프라인을 사용하므로 CSS Modules, SCSS 등의 처리가 자연스럽고, toMatchScreenshot API가 간결하여 테스트 코드의 가독성이 높다는 점입니다.

팀 내에서 테스트 방침을 검토하는 과정과 다른 도구와의 비교에 대해서는 [React] 모노레포 환경에서의 공통 컴포넌트 테스트 방침을, 기존 Jest 프로젝트에 Vitest를 점진적으로 도입하는 방법에 대해서는 [React] Jest 프로젝트에 Vitest 도입하기 - View 테스트 분리 전략도 참고해 보시기 바랍니다.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS