Playwright Component Testing: 실제 브라우저에서 React·Vue·Svelte 컴포넌트 테스트하기

이 글의 핵심

Playwright Component Testing은 E2E와 단위 테스트 사이에서 컴포넌트를 실제 브라우저 엔진으로 마운트해 검증하는 방식입니다. 이 글에서는 프레임워크별 설정, 상호작용·스냅샷·MSW·CI까지 한 번에 정리합니다.

이 글의 핵심

Playwright Component Testing(CT)은 단일 컴포넌트를 실제 브라우저에서 마운트하고, Playwright의 Locator와 Assertion으로 상태와 스타일까지 검증하는 테스트 방식입니다. 전체 앱을 띄우는 E2E보다 범위가 좁고, DOM만 흉내 내는 jsdom 기반 테스트보다 실제 브라우저 동작에 가깝습니다. 이 글에서는 동작 방식과 React·Vue·Svelte별 설정, 상호작용과 비주얼 회귀 테스트, MSW로 네트워크를 고정하는 방법, CI 구성, Cypress CT와의 차이를 다룹니다.


왜 컴포넌트 테스트인가

프론트엔드 테스트는 보통 다음 층으로 나뉩니다.

구분대표 도구강점한계
단위·통합(가상 DOM)Vitest, Jest + Testing Library빠르고 피드백이 즉각적레이아웃·실제 CSS·브라우저 차이 반영이 어려움
컴포넌트(실제 브라우저)Playwright CT, Cypress CT레이아웃·스크롤·포커스 등 브라우저 동작 반영E2E보다는 가볍지만 가상 DOM 테스트보다 느림
E2EPlaywright, Cypress사용자 여정 전체 검증느리고 불안정(flaky)해지기 쉬움

jsdom은 레이아웃을 계산하지 않으므로 getBoundingClientRect()가 항상 0을 돌려주고, CSS의 :hover나 overflow 동작, IntersectionObserver 같은 API는 직접 흉내 내야 합니다. 드롭다운이 화면 밖으로 잘리는지, 긴 텍스트가 말줄임 처리되는지, 포커스가 모달 안에 갇히는지 같은 문제는 실제 브라우저에서만 확인됩니다. Playwright CT는 이런 검증을 폼 한 블록, 모달, 데이터 테이블처럼 재사용 컴포넌트 단위로 할 수 있게 해 줍니다. 라우팅·결제·권한 같은 여러 화면에 걸친 흐름은 여전히 E2E가 필요합니다.

공식 문서는 컴포넌트 테스트를 실험적(experimental) 기능으로 표시하고 있고, 패키지 이름에도 experimental-이 붙어 있습니다. 마이너 버전 업그레이드에서도 API가 바뀔 수 있으므로 버전을 고정하고 릴리스 노트를 확인하는 편이 안전합니다.


동작 방식

mount와 Locator

CT의 중심은 mount 픽스처입니다. 테스트를 실행하면 Playwright가 테스트 파일에서 참조하는 컴포넌트들을 Vite로 번들하고, playwright/index.html을 띄운 브라우저 페이지에 그 번들을 올립니다. 테스트 코드 자체는 Node.js에서 실행되고, mount(<Button />)을 호출하면 컴포넌트를 렌더링하라는 요청이 브라우저로 전달됩니다. 반환값은 마운트된 컴포넌트를 가리키는 Locator라서 click, fill, toHaveText, toHaveScreenshot 같은 기존 Playwright API를 그대로 씁니다.

테스트는 Node에서, 컴포넌트는 브라우저에서 실행된다는 이 구조 때문에 몇 가지 제약이 생깁니다. mount에 넘기는 props는 문자열, 숫자, 날짜, 일반 객체처럼 직렬화 가능한 값이어야 하고, 클래스 인스턴스나 DOM 노드 같은 살아 있는 객체는 넘길 수 없습니다. onClick 같은 콜백 props는 넘길 수 있지만, 브라우저에서 호출되면 Node 쪽 함수가 비동기로 실행되므로 콜백의 반환값을 컴포넌트가 동기로 받아 쓰는 구조는 동작하지 않습니다. 또 테스트 파일 안에서 컴포넌트를 정의하면 번들 대상이 되지 않으므로, 테스트 전용 래퍼 컴포넌트가 필요하면 별도 파일로 분리해야 합니다.

진입 스크립트 playwright/index.*

전역 스타일, 테마, i18n 초기화처럼 앱 전체에서 한 번만 필요한 것은 playwright/index.tsx(또는 index.ts)에서 불러옵니다. 프로덕션의 main.tsx와 최대한 같은 것을 불러오면, 테스트에서만 스타일이 빠져 보이는 식의 환경 차이로 인한 실패를 줄일 수 있습니다.


시작하기

프로젝트에 처음 붙일 때는 공식 스캐폴딩을 씁니다. 실행하면 사용할 프레임워크를 고르게 하고 필요한 패키지를 설치합니다.

npm init playwright@latest -- --ct

생성되는 파일은 다음과 같습니다.

  • playwright/index.html: 컴포넌트가 마운트될 #root가 있는 엔트리 HTML
  • playwright/index.tsx: 전역 스타일과 Provider를 불러오는 스크립트
  • playwright-ct.config.ts: CT 전용 Playwright 설정

package.json에는 이 설정 파일을 지정하는 스크립트가 추가됩니다.

{
  "scripts": {
    "test-ct": "playwright test -c playwright-ct.config.ts"
  }
}

E2E용 playwright.config.ts와 설정 파일이 분리되어 있으므로, -c 없이 npx playwright test를 실행하면 컴포넌트 테스트가 아니라 E2E 설정이 쓰인다는 점에 주의합니다.


React

npm install -D @playwright/experimental-ct-react
// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test('클릭 시 라벨이 바뀐다', async ({ mount }) => {
  const component = await mount(<Button label="전송" />);
  await expect(component).toHaveText('전송');
  await component.click();
  await expect(component).toHaveText('완료');
});

test('클릭하면 onSubmit이 호출된다', async ({ mount }) => {
  let submitted = false;
  const component = await mount(<Button label="전송" onSubmit={() => { submitted = true; }} />);
  await component.click();
  await expect.poll(() => submitted).toBe(true);
});

Button이 <button> 하나를 렌더링한다면 component 자체가 그 버튼을 가리키므로 바로 click할 수 있습니다. 컴포넌트 내부의 여러 요소 중 하나를 고를 때는 component.getByRole(...), getByLabel(...)처럼 역할과 레이블 기반 쿼리를 쓰는 것이 E2E와 같은 권장 방식입니다.

두 번째 테스트에서 expect.poll을 쓰는 이유는 앞에서 말한 구조 때문입니다. 콜백은 브라우저에서 클릭이 일어난 뒤 Node로 메시지가 건너와서 실행되므로, click()이 반환된 직후에는 아직 submitted가 바뀌지 않았을 수 있습니다.

Vite 설정 맞추기

CT는 프로젝트의 vite.config를 자동으로 읽지 않습니다. 경로 별칭(@/)이나 SVG 플러그인 같은 설정은 playwright-ct.config.ts의 use.ctViteConfig에 따로 넣어야 합니다.

// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';
import path from 'node:path';

export default defineConfig({
  testDir: './src',
  use: {
    ctViteConfig: {
      resolve: { alias: { '@': path.resolve(__dirname, './src') } },
    },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

ctViteConfig에 plugins를 직접 지정하면 기본으로 들어가던 프레임워크 플러그인(예: @vitejs/plugin-react)도 직접 넣어야 할 수 있으니, 플러그인을 추가할 때는 프레임워크 플러그인이 빠지지 않았는지 확인합니다.


Vue

npm install -D @playwright/experimental-ct-vue
// Counter.spec.ts
import { test, expect } from '@playwright/experimental-ct-vue';
import Counter from './Counter.vue';

test('증가 버튼', async ({ mount }) => {
  const component = await mount(Counter, {
    props: { initial: 0 },
  });
  await component.getByRole('button', { name: '증가' }).click();
  await expect(component.getByTestId('count')).toHaveText('1');
});

Vue는 mount의 두 번째 인자로 props, slots, on(이벤트 핸들러)을 넘깁니다. 스토어나 라우터가 필요한 컴포넌트는 아래 beforeMount 훅에서 설치하고, 테스트마다 달라지는 초기값은 hooksConfig로 전달합니다.


Svelte

npm install -D @playwright/experimental-ct-svelte
// Alert.spec.ts
import { test, expect } from '@playwright/experimental-ct-svelte';
import Alert from './Alert.svelte';

test('닫기 클릭 시 사라진다', async ({ mount }) => {
  const component = await mount(Alert, {
    props: { message: '저장됨' },
  });
  await expect(component.getByText('저장됨')).toBeVisible();
  await component.getByRole('button', { name: '닫기' }).click();
  await expect(component.getByText('저장됨')).toBeHidden();
});

Svelte도 Vue와 같은 형태로 컴포넌트와 props를 넘깁니다. 사용하는 Svelte 메이저 버전(4와 5는 마운트 API가 다릅니다)을 Playwright 버전이 지원하는지 릴리스 노트에서 확인하고, Vite 플러그인 버전을 프로덕션 빌드와 맞추는 것이 좋습니다.


상호작용과 Provider 주입

사용자 이벤트

click, dblclick, fill, press, hover, focus는 E2E와 같고, Locator의 자동 대기도 그대로 적용됩니다. 타이밍 문제를 waitForTimeout으로 풀면 느리고 불안정한 테스트가 되므로, “이 상태가 될 때까지 기다린다”는 의미의 web-first assertion(toBeVisible, toHaveText)으로 표현하는 편이 맞습니다.

beforeMount로 래핑

라우터, 테마, 상태 관리 Provider처럼 마운트 전에 감싸야 하는 것은 playwright/index.tsx에서 beforeMount 훅으로 처리합니다. 테스트에서 hooksConfig를 넘기면 훅이 그 값을 받아 래핑 방식을 바꿉니다.

// playwright/index.tsx
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { BrowserRouter } from 'react-router-dom';
import '../src/styles/global.css';

export type HooksConfig = { enableRouting?: boolean };

beforeMount<HooksConfig>(async ({ App, hooksConfig }) => {
  if (hooksConfig?.enableRouting) {
    return (
      <BrowserRouter>
        <App />
      </BrowserRouter>
    );
  }
});
// NavLink.spec.tsx
import type { HooksConfig } from '../playwright';

test('라우터 안에서 렌더링', async ({ mount }) => {
  const component = await mount<HooksConfig>(<NavLink to="/settings">설정</NavLink>, {
    hooksConfig: { enableRouting: true },
  });
  await expect(component).toHaveAttribute('href', '/settings');
});

hooksConfig도 브라우저로 전달되는 값이므로 직렬화 가능해야 합니다. 스토어 인스턴스 자체가 아니라 초기 상태 객체를 넘기고, 훅 안에서 스토어를 만드는 식으로 설계합니다.

비동기 데이터

데이터를 직접 가져오는 컴포넌트는 MSW로 응답을 고정하거나, 데이터를 props로만 받는 프레젠테이션 컴포넌트로 쪼개 CT 대상을 좁히는 편이 안정적입니다. 실제 API에 의존하면 응답 속도와 데이터 변화 때문에 테스트 결과가 흔들립니다.


비주얼 회귀 테스트

toHaveScreenshot()은 스크린샷을 기준 이미지와 픽셀 단위로 비교합니다. mount가 돌려준 Locator에 쓰면 그 컴포넌트 영역만 캡처하므로 페이지 전체를 찍는 E2E보다 노이즈가 적습니다.

test('카드 비주얼 회귀', async ({ mount }) => {
  const component = await mount(<PricingCard plan="pro" />);
  await expect(component).toHaveScreenshot('pricing-pro.png');
});

처음 실행하면 기준 이미지가 없어 테스트가 실패하면서 이미지를 만들고, 이후 실행부터 비교합니다. 기준 이미지를 갱신하려면 --update-snapshots로 실행합니다. 기준 이미지는 브라우저와 OS별로 따로 저장되는데(파일 이름에 -chromium-linux 같은 접미사가 붙음), 폰트 렌더링과 안티앨리어싱이 OS마다 다르기 때문입니다. 그래서 로컬 macOS에서 만든 기준 이미지는 Linux CI에서 거의 항상 실패합니다. 기준 이미지 생성과 비교를 모두 CI와 같은 Docker 이미지(예: mcr.microsoft.com/playwright)에서 하는 것이 가장 확실하고, 불가피하면 maxDiffPixelRatio 같은 허용 오차를 둡니다. 애니메이션은 기본으로 비활성화된 상태에서 캡처되지만, 날짜나 랜덤 값처럼 매번 바뀌는 내용은 props로 고정하거나 mask 옵션으로 가려야 합니다.

스크린샷 테스트를 모든 컴포넌트에 걸면 기준 이미지가 금방 수백 장이 되고, 디자인 토큰 하나만 바꿔도 대량으로 갱신해야 합니다. 디자인 시스템의 핵심 컴포넌트나 레이아웃이 깨지기 쉬운 컴포넌트에 한정하는 편이 관리하기 쉽습니다.


MSW로 네트워크 고정

Playwright CT는 router 픽스처를 제공하고, 여기에 MSW의 http 핸들러를 그대로 넘길 수 있습니다. 앱 개발이나 Storybook에서 쓰는 handlers 배열을 재사용하면 테스트와 개발 환경의 목 응답이 일치합니다.

import { http, HttpResponse } from 'msw';
import { handlers } from '../src/mocks/handlers';

test.beforeEach(async ({ router }) => {
  await router.use(...handlers);
});

test('목 데이터로 렌더', async ({ mount }) => {
  const component = await mount(<UserCard userId="1" />);
  await expect(component.getByText('테스트')).toBeVisible();
});

test('서버 오류 시 안내 문구', async ({ mount, router }) => {
  await router.use(
    http.get('/api/user', () => new HttpResponse(null, { status: 500 })),
  );
  const component = await mount(<UserCard userId="1" />);
  await expect(component.getByText('불러오지 못했습니다')).toBeVisible();
});

router.use로 나중에 추가한 핸들러가 먼저 매칭되므로, 공통 핸들러를 beforeEach에 깔고 특정 테스트에서만 덮어쓰는 구성이 됩니다. 이 방식은 Playwright의 네트워크 가로채기 위에서 동작하므로 서비스 워커를 따로 등록할 필요가 없습니다. 반대로 playwright/index.tsx에서 msw/browser의 setupWorker로 워커를 직접 시작하는 방법도 있지만, 워커 스크립트 파일을 서빙해야 하고 테스트별로 핸들러를 바꾸기 번거롭습니다. 간단한 응답 하나만 바꿀 때는 router.route()(E2E의 page.route()와 같은 형태)로 직접 응답을 지정해도 됩니다.

요청이 목으로 잡히지 않는다면 핸들러의 URL이 컴포넌트가 실제로 요청하는 URL과 같은지 먼저 봅니다. 상대 경로와 절대 경로, 쿼리 문자열, 끝의 슬래시 차이로 매칭이 안 되는 경우가 많습니다.


CI 통합

npx playwright install --with-deps chromium webkit
npm run test-ct -- --reporter=html

CI 이미지에 브라우저 바이너리가 없으면 playwright install이 필요합니다. Linux에서는 브라우저 실행에 필요한 시스템 라이브러리가 빠져 실패하는 경우가 많아 --with-deps로 함께 설치합니다. 공식 Docker 이미지를 쓰면 이 단계가 필요 없고, 스크린샷 기준 이미지 환경도 맞출 수 있습니다.

실패했을 때 원인을 보려면 HTML 리포트와 트레이스(trace: 'on-first-retry')를 CI 아티팩트로 저장합니다. 테스트가 많아지면 --shard=1/4처럼 샤드로 나눠 여러 CI 잡에서 병렬로 돌릴 수 있습니다. 컴포넌트 번들은 각 샤드에서 따로 빌드되므로 샤드 수를 늘린다고 시간이 그만큼 줄지는 않습니다.


Cypress CT와 비교

항목Cypress CTPlaywright CT
APICypress 전용 명령 체인E2E와 같은 Playwright API
실행 구조테스트 코드가 브라우저 안에서 실행테스트는 Node, 컴포넌트는 브라우저
브라우저Chrome 계열, Firefox, WebKit(실험적)Chromium, Firefox, WebKit
성숙도정식 기능, 자료가 많음실험적 표기
디버깅타임 트래블 UITrace Viewer, UI 모드

실행 구조의 차이가 실무에서 가장 크게 느껴집니다. Cypress CT는 테스트 코드가 컴포넌트와 같은 브라우저에서 돌기 때문에 클래스 인스턴스나 함수를 props로 자유롭게 넘기고 컴포넌트 내부 객체를 직접 다룰 수 있습니다. Playwright CT는 경계가 있어 그런 코드를 쓸 수 없는 대신, 테스트가 E2E와 같은 방식으로 작성되고 같은 리포트와 트레이스를 씁니다. 이미 Playwright로 E2E를 운영 중이라면 도구를 하나로 모으는 이점이 크고, Cypress에 익숙한 팀이라면 Cypress CT가 전환 비용이 적습니다.


커버리지와 역할 분담

CT는 브라우저에서 실행된 번들을 기준으로 하므로, Node에서 도는 Vitest 커버리지와 숫자를 합치려면 Istanbul 계측을 따로 설정해야 해서 번거롭습니다. 그래서 디자인 시스템처럼 렌더링 결과가 중요한 컴포넌트는 CT로 동작과 스크린샷을 확인하고, 비즈니스 로직은 순수 함수로 분리해 Vitest로 커버리지를 관리하는 식으로 나누는 경우가 많습니다. 실제 트래픽 경로와 여러 화면에 걸친 흐름은 여전히 스테이징 E2E와 모니터링이 맡습니다.


같이 보면 좋은 글