← Back to list

Playwright로 하는 Component Test와 E2E Test Coverage

안녕하세요. 서비스웹개발팀의 프론트엔드 개발자 헨비입니다. 이번 고객센터 채팅 상담 프로젝트는 Web과 Mobile Web, 그리고 “여기어때” 앱 내의 WebView(Android/iOS) 환경에서 동시에 작동하는 서비스입니다.

Henby in 여기어때 기술블로그 · 2025-09-02 06:13 · 128 claps · 20.2 min read
#tech #playwrights
Open on Medium ↗
Wiki topics: RAG · RAG & Retrieval

Playwright로 하는 Component Test와 E2E Test Coverage

안녕하세요. 서비스웹개발팀의 프론트엔드 개발자 헨비입니다. 이번 고객센터 채팅 상담 프로젝트는 Web과 Mobile Web, 그리고 “여기어때” 앱 내의 WebView(Android/iOS) 환경에서 동시에 작동하는 서비스입니다.

실시간 상호작용과 멀티 디바이스 환경에서 안정성을 보장하기 위해 단위 테스트와 E2E 테스트를 도입했습니다.

특히 핵심 사용자 여정(접속→연결→메시지/파일→종료)은 수동 테스트만으로는 리스크를 충분히 검증하기 어렵다고 판단했습니다.

이러한 이유로 테스트 코드의 필요성을 절실히 느꼈고, 그 과정과 선택을 이번 글에서 공유하고자 합니다.

1. 왜 Playwright를 선택했는가

GUI E2E Test Tool 중의 양대산맥인 CypressPlaywright 가 있습니다. 각자의 장점과 단점을 알아보고 왜 Playwright를 선택하게 되었는지 같이 체크 해보겠습니다.

Cypress 장점

  • GUI 기반 Test Runner와 Time‑Travel 덕분에 개발자 경험(DX)이 뛰어납니다.
  • 방대한 생태계와 네트워크 스텁 기능, 그리고 친절한 문서가 초보자 진입 장벽을 낮춰줍니다.
  • 설정과 사용 흐름이 단순해서 빠르게 시작할 수 있습니다.
  • 결론적으로 테스트코드의 이해와 작성이 쉽고 UI가 이쁩니다.

Cypress 단점

  • 브라우저 엔진과 탭, 도메인 제어에 제약이 있어, 복잡한 시나리오 테스트에는 부적합할 수 있습니다.
  • Safari(WebKit)를 지원하지 않아 iOS Safari 호환성을 보장하기 어렵습니다.

Playwright 장점

  • Chromium, Firefox, WebKit까지 모두 공식 지원해 크로스 브라우징 커버리지가 넓습니다.
  • 멀티 탭, 권한 설정, 네트워크 제어, 디바이스 및 GPS 시뮬레이션 등 고급 브라우저 제어 기능이 훨씬 풍성합니다.자동 대기, 병렬 실행, Trace/비디오/스크린샷 캡처 같은 견고한 테스트 인프라가 기본 포함되어 있습니다.

Playwright 단점

  • Cypress의 UI Runner처럼 한눈에 보여지는 인터페이스와 같은 개발자 통합 경험은 아니어서, 일부에겐 다소 불편할 수 있습니다.

선택 기준과 결론

  • 본 서비스는 고객센터 “실시간 채팅” 앱으로 iOS Safari(WebKit) 호환성과 모바일 뷰 재현성이 매우 중요합니다. 또한 파일 업로드/미디어 재생, 웹소켓 기반 메시징 등 브라우저 레벨 제어가 필요했습니다.
  • 멀티 브라우저(WebKit 포함)와 모바일 디바이스 프로필과 후에 포커싱/권한/다운로드까지 포괄하는 제어가 필요했기에 Playwright를 선택했습니다. 더군다나 Web과 Webview 모두에서 사용되는 앱이므로 많은 멀티 브라우저와 다양한 모바일 디바이스 프로필이 있다는 점이 매력적 이였습니다.

Component Test, E2E가 모두 필요한 이유

  • 실시간 채팅은 “UI 상호작용의 촘촘함”과 “End-To-End 시나리오의 연속성”이 모두 중요합니다.
  • 컴포넌트 테스트(CT)로는 채팅에 접속해서 바로 만나는 다양한 컴포넌트들의 핵심 UI의 상태 전이(비활성/활성, 파일 선택, 미디어 렌더와 닫기)를 빠르고 결정적으로 검증했습니다.
  • 채팅 접속 → 카테고리 선택 → 상담 연결 → 파일 전송(이미지/비디오) → 종료까지 실제 사용자 여정을 통합 검증했습니다.
  • 두 층을 병행함으로써 “UI 동작 신뢰도”와 “사용자 플로우 신뢰도”를 동시에 담보했습니다.

2. 목표와 범위

  • 사용하는 모든 컴포넌트의 테스트 + E2E 커버리지 총 80%를 목표로 했습니다.
  • 수집 방식은 Vite 인스트루멘테이션(istanbul) + Playwright afterEach 수집 + 사후 병합으로 구성해, 브라우저 실행 환경의 라인 커버리지를 산출했습니다.

E2E 테스트로 달성하려 한 것

  • 유저 타입에 따른 접속과 리다이렉트 검증
  • 카테고리 선택 및 상담원 연결 검증
  • 회원 정보에 따른 분기 처리 검증
  • 일반 텍스트 메세지와 이미지, 비디오 파일 첨부 메세지 검증
  • 유저 타입에 따른 상담 종료 분기 처리 및 종료 처리 검증
  • 각 E2E를 Chromium/WebKit/모바일 프로필에서 동일 시나리오 검증

검증하는 사용자 E2E 사용자 플로우

  • 비회원 사용자의 접속 → 카테고리 선택 → 상담원 연결 → 메세지 보내기 → 상담 종료 버튼 과 팝업 체크 → 상담 종료
  • 회원의 첫 접속 → 카테고리 선택 → 상담원 연결 → 회원 정보 수신 → 상담원 연결 → 메세지 보내기 → 상담 종료
  • 기존 회원의 접속 → 카테고리 선택 → 채팅 리스트 체크 → 상담원 연결 → 이미지, 비디오 파일 첨부 메세지 → 상담 종료

3. 프로젝트 구성과 테스트 환경

본 프로젝트는 React 19와 Vite 기반의 SPA로, TanStack Router로 라우팅을 구성하고 Sendbird SDK로 실시간 채팅을 처리합니다.

테스트는 Playwright를 채택하여 Chromium/WebKit/모바일 프로필의 멀티 프로젝트로 병렬 실행하며, 컴포넌트 테스트(CT)와 E2E를 병행 운용합니다.

E2E 커버리지는 E2E_COVERAGE 환경변수로 활성화되는 Vite용 Istanbul 인스트루멘트 플러그인(serve 시 주입)과 Playwright afterEach 픽스처 수집, 사후 병합 스크립트로 리포트를 생성합니다.

실행은 npm run test-e2e / test-ct / test-e2e:coverage + :ui 스크립트로 표준화했습니다.

Package.json 구성 예시 :ui가 붙는다면 playwright 특유의 GUI로 하는 테스트를 할 수 있습니다.

"scripts": {
    ...,
    "test-e2e": "playwright test -c playwright.config.ts",
    "test-e2e:ui": "playwright test -c playwright.config.ts --ui",
    "test-ct": "playwright test -c playwright-ct.config.ts",
    "test-ct:ui": "playwright test -c playwright-ct.config.ts --ui",
    "test-e2e:coverage": "rm -rf .nyc_output coverage-e2e; E2E_COVERAGE=1 playwright test -c playwright.config.ts; node tests/coverageScript/merge-e2e-coverage.mjs",
    "test-e2e:coverage:ui": "rm -rf .nyc_output coverage-e2e; E2E_COVERAGE=1 playwright test -c playwright.config.ts --ui; node tests/coverageScript/merge-e2e-coverage.mjs"
  },

  "devDependencies": {
    "@playwright/experimental-ct-react": "^1.54.2",
    "@playwright/test": "^1.54.2",
    "@testing-library/dom": "^10.4.0",
    "@testing-library/react": "^16.2.0",
    "istanbul-lib-coverage": "^3.2.2",
    "istanbul-lib-instrument": "^6.0.3",
    "istanbul-lib-report": "^3.0.1",
    "istanbul-reports": "^3.1.7",
  }

5. Component Test

컴포넌트 테스트는 conponents/ 폴더 내에 각 컴포넌트 별 *.spec.tsx 에 정의하고 있습니다. playwright-ct.config.ts 에서 PC 크롬, 모바일 크롬, Safari(WebKit) 를 테스트 합니다.

projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], locale: 'ko-KR' },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'], viewport: { width: 375, height: 844 }, locale: 'ko-KR' },
    },
    {
      name: 'WebKit',
      use: {
        ...devices['Desktop Safari'],
        browserName: 'webkit',
        viewport: { width: 375, height: 844 },
        locale: 'ko-KR',
        headless: true, 
        trace: 'on-first-retry',
      },
    },
    {
      name: 'WebKit Mobile',
      use: {
        ...devices['iPhone 12'],
        browserName: 'webkit',
        viewport: { width: 375, height: 844 },
        locale: 'ko-KR',
        headless: true,
        trace: 'on-first-retry',
      },
    },
  ],

먼저 예시로 보여 드릴 FileMessage component는 상담사와 유저가 첨부한 이미지, 비디오 파일 메세지를 보여주고 클릭시 모달로 재생 또는 확대 할 수 있기 때문에 클릭 이벤트와 재전송 기능을 위해 총 2가지 클릭 이벤트를 가지고 있습니다.

 test('나의 이미지 4개의 파일 메세지 컴포넌트의 랜더링 체크', async ({ mount, page }) => {
    const log: any[] = [];
    const userType = 'me';
    // 클릭 이벤트와 로그 적재
    const onClickMedia = () => {
      log.push('onClickMedia');
    };

    await mount(
      <MediaViewerProvider>
        <FileMessage
          status="sent"
          timestamp={new Date(1747721095510 + 1900000).valueOf()}
          fileInfoList={imageFileInfoList4}
          userType={userType}
          onClickMedia={onClickMedia}
        />
      </MediaViewerProvider>,
    );

    const imageElement = page.locator('img');
    await expect(imageElement).toBeVisible();
    // 랜더링 체크
    await imageElement.click();
    // 클릭 이벤트

    await expect(imageElement).toHaveCount(4);
    // 4개의 파일이 잘 랜더링 되었는지
    await expect(page.getByText('3:36 오후')).toBeVisible();
    // 전송 시간의 랜더링 체크
  });

또한 비디오 같은 대용량 파일의 경우 전송 실패 상태에 “재전송” 이벤트를 가질 수 있습니다.

test('나의 비디오 1개의 파일 메세지 컴포넌트의 재전송 기능 및 랜더링 체크', async ({ mount, page }) => {
    const log: any[] = [];
    const userType = 'me';
    const onClickError = () => {
      log.push('onClickError');
    };
    await mount(
      <MediaViewerProvider>
        <FileMessage
          status="error"
          timestamp={new Date(1747722095510).valueOf()}
          fileInfoList={videoFileInfoList}
          userType={userType}
          onClickError={onClickError}
        />
      </MediaViewerProvider>,
    );
  await expect(page.getByText('재전송')).toBeVisible();
  const button = page.locator('.message-error', { hasText: '재전송' });
  await expect(button).toBeVisible();
  await button.click();
  expect(log).toContain('onClickError');
});

57개의 테스트를 4개의 브라우저에서 모두 검증하여, 총 228개의 테스트를 통과 했습니다.

5. E2E Coverage Test 실행 흐름 및 요약

1.초기화

  • rm -rf .nyc_output coverage-e2e
  • 이전 커버리지 조각(.nyc_output)과 리포트(coverage-e2e)를 삭제합니다.
  1. 테스트 실행 + 인스트루멘테이션
  • E2E_COVERAGE=1 playwright test -c playwright.config.ts
  • E2E_COVERAGE=1로 Vite dev 서버가 커버리지 인스트루멘트 모드로 뜹니다.
  • 앱 코드가 제공될 때, Vite가 소스에 커버리지 수집 코드를 주입하고 index.html에 coverage 스텁을 삽입합니다.
  • E2E 스펙들이 실행되고, 각 테스트가 끝날 때마다 커버리지 스냅샷이 .nyc_output에 떨어집니다.
  1. 리포트 생성
  • node merge-e2e-coverage.mjs
  • .nyc_output의 여러 json 조각을 병합하여 coverage-e2e/index.html과 텍스트 요약을 생성합니다.

5. 파일별 역할

  • istanbul-inject.ts
  • Vite 플러그인. E2E_COVERAGE=1일 때만 동작.
  • src/*/.{ts,tsx,js,jsx} 변환 시 커버리지 코드 삽입(transform).
  • index.html에 window.coverage 초기 스텁 스크립트 삽입(transformIndexHtml).
  • 시점: 테스트 시작 시 dev 서버가 소스를 서빙할 때.
const instrumenter = createInstrumenter({ esModules: true, produceSourceMap: false });

export default function istanbulInstrument(): Plugin {
  return {
    name: 'istanbul-instrument',
    enforce: 'post',
    apply: 'serve',
    transform(code, id) {
      if (!process.env.E2E_COVERAGE) return null;
      // 커버리지 수집이 비활성(E2E_COVERAGE 미설정)인 경우 변환하지 않음
      if (!/src\/.*\.(tsx?|jsx?)$/.test(id)) return null;
      // src 디렉토리 내 TS/TSX/JS/JSX 파일만 대상
      if (id.includes('node_modules')) return null;
      // 외부 라이브러리는 변환 제외
      try {
        // 커버리지 코드 삽입 수행
        const instrumented = instrumenter.instrumentSync(code, id);
        return { code: instrumented, map: null };
        // Vite가 요구하는 반환 형태: 코드와 소스맵(생략)
      } catch (e) {
        console.warn('[istanbul-instrument] 실패:', id, e);
        return null;
      }
    },
    transformIndexHtml(html) {
      if (!process.env.E2E_COVERAGE) return html;
      // 커버리지 수집이 비활성인 경우 원본 반환
      return html.replace(
        '</head>',
        `<script>(function(){window.__coverage__ = window.__coverage__ || {};})();</script></head>`
      );
    },
  };
}
  • fixtures.ts
  • Playwright 확장 fixtures. afterEach 훅에서 window.coverage를 읽어 .nyc_output/*.json으로 저장하고 테스트 아티팩트로 첨부.
  • 시점: “각 테스트가 끝날 때마다” 실행.
base.afterEach(async ({ page }, testInfo) => {
  // 각 테스트 종료 후 실행되는 훅: 브라우저 페이지에서 커버리지 수집
  try {
    // 페이지 컨텍스트에서 window.__coverage__를 읽어옴
    const coverage = await page.evaluate(() => (globalThis as any).__coverage__);
    if (coverage) {
      const nycDir = path.resolve(process.cwd(), '.nyc_output');
      // 디렉토리가 없으면 생성
      if (!fs.existsSync(nycDir)) fs.mkdirSync(nycDir, { recursive: true });
      const file = path.join(nycDir, `coverage-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
      // 커버리지 맵을 JSON으로 저장
      await fs.promises.writeFile(file, JSON.stringify(coverage), 'utf-8');
      testInfo.attachments.push({ name: 'coverage', path: file, contentType: 'application/json' });
    }
  } catch (e) {
  }
});
  • merge-e2e-coverage.mjs
  • .nyc_output의 다수 json을 병합 후 coverage-e2e에 HTML 리포트와 텍스트 요약 생성.
  • 시점: 전체 테스트 완료 직후 단 한 번 실행.
const nycDir = path.resolve(process.cwd(), '.nyc_output');
if (!fs.existsSync(nycDir)) {
  process.exit(0);
}
const files = fs.readdirSync(nycDir).filter((f) => f.endsWith('.json'));
// 디렉토리 내 json 파일 목록 수집
const map = createCoverageMap({});
// 빈 커버리지 맵 생성
for (const f of files) {
  try {
    const data = JSON.parse(fs.readFileSync(path.join(nycDir, f), 'utf-8'));
    map.merge(data);
  } catch (e) {
    console.warn('커버리지 파일 파싱 실패:', e);
  }
}
const context = createContext({ dir: 'coverage-e2e', coverageMap: map });
try {
  // HTML 리포트 생성기
  const htmlReport = reports.create('html');
  const textSummary = reports.create('text-summary');
  // 텍스트 요약 리포트 생성기
  htmlReport.execute(context);
  textSummary.execute(context);
  console.log('\nE2E 커버리지 리포트 생성 완료: coverage-e2e/index.html');
} catch (e) {
  console.error('커버리지 리포트 생성 중 오류:', e);
  process.exit(1);
}

요약

  • before: 폴더 정리
  • during: Vite 플러그인이 인스트루먼트테스트 실행 → fixtures가 테스트마다 수집
  • after: merge-e2e-coverage.mjs 스크립트로 리포트 생성

7. E2E coverage Test

E2E Test는 3개의 시나리오를 가지고 있습니다.

비회원 유저의 접속

  1. 채팅 목록이 없으므로 자동으로 센드버드 그룹 채널을 개설하여 “/chat/{chatid}”redirect
  2. 상담 카테고리 버튼들이 랜더링 → 카테고리 선택
  3. 상담원과 연결되며 MessageInput 이 활성화
  4. 메세지 보내기
  5. 상담 종료 버튼을 클릭하여 종료 경고 팝업이 뜨는지 체크
  6. 상담 종료 버튼 선택
  7. 완전한 상담사와의 연결 종료

처음 진입하는 회원의 접속

  • 비회원 유저와 비슷하여 생략

기존에 상담을 진행했던 회원의 접속

  • 특정 userId를 가지고 채팅에 접속
  • 기존 상담 목록이 있는지 체크
  • “새로운 상담하기” 버튼 클릭
  • 센드버드에 새로운 그룹 채널을 개설 후 redirect되는지 체크
  • 이미지 파일, 비디오 파일을 각각 1회씩 업로드 체크
  • 상담 종료 진행

중요 기능의 테스트를 완료하여 목표 Coverage 인 80%를 달성하였습니다. 각 항목의 설명을 하자면

  • Statements (구문 커버리지) 소스 코드의 모든 구문(statement) 들 중 테스트에서 실제 실행된 구문의 비율입니다.
  • Branches (분기 커버리지) 조건문(if/else, switch/case, 삼항연산자 등)의 모든 분기(branch) 가 테스트에서 실행된 비율입니다 예: if (cond) {…} else {…} → true/false 두 경우 모두 커버돼야 100%.
  • Functions (함수 커버리지) 정의된 함수/메서드들이 한 번이라도 호출 되었는지의 비율입니다.
  • Lines (라인 커버리지) 소스 파일의 코드 라인(line) 단위로 카운트, 가장 직관적이고 많이 보는 지표 입니다.

사용자 플로우 중심의 E2E 테스트를 하는 Front-End 에서 80%의 커버리지는 프로젝트의 핵심 기능이 문제가 되었을 때, 문제를 발견할 가능성이 높습니다.

설정한대로 coverage-e2e/index.html 에 리포트도 생성이 됩니다. 어느 부분이 모자라는지 체크할 수 있습니다.

8. 결론

이번 프로젝트에서 CypressPlaywright를 비교한 끝에, 멀티 브라우저(WebKit 포함) 지원과 고급 브라우저 제어 기능을 제공하는 Playwright를 채택했습니다. 이는 고객센터 실시간 채팅이라는 서비스 특성상 iOS Safari 호환성, 파일 전송, 미디어 재생, 웹소켓 메시징 등 브라우저 레벨 제어가 핵심이었기 때문입니다.

Playwright 기반으로 컴포넌트 테스트와 E2E 테스트를 병행하면서, UI 단위의 세밀한 상호작용과 실제 사용자 여정 전체를 동시에 검증할 수 있었습니다. 그 결과 라인 커버리지 80%를 달성하였고 서비스의 핵심 플로우가 안정적으로 동작하고 있음을 확인했습니다.

앞으로 고객상담채팅에 많은 개선 기능들이 추가될 예정으로 그 전에 다양한 컴포넌트의 검증과 E2E 테스트를 구성하여 안정적인 서비스를 구축했습니다.

향후에는 상대적으로 낮은 분기 커버리지 개선, 모바일 디바이스 테스트 시나리오 확대, CI/CD 파이프라인 테스트 자동화 등을 통해 테스트 체계를 한층 더 견고하게 구축할 예정입니다.

마지막까지 긴 글을 읽어주셔서 감사합니다. 이번 경험이 다른 프론트엔드 개발자분들이 테스트 커버리지를 고민하고 구축할 때 작은 참고가 되길 바랍니다.


메타데이터
post_id
424c1e3a5b2f
slug
playwright로-하는-component-test와-e2e-test-coverage-424c1e3a5b2f
url
https://techblog.gccompany.co.kr/playwright%EB%A1%9C-%ED%95%98%EB%8A%94-component-test%EC%99%80-e2e-test-coverage-424c1e3a5b2f
canonical_url
https://techblog.gccompany.co.kr/playwright%EB%A1%9C-%ED%95%98%EB%8A%94-component-test%EC%99%80-e2e-test-coverage-424c1e3a5b2f
author_url
https://medium.com/@henby
status
ok
fetched_at
2026-06-10 08:17:25