grep

QA

Playwright로 하는 Component Test와 E2E Test Coverage

Henby여기어때

2025년 9월 2일

원문에서 보기 ↗

안녕하세요. 서비스웹개발팀의 프론트엔드 개발자 헨비 입니다.

이번 고객센터 채팅 상담 프로젝트는 Web과 Mobile Web , 그리고 “여기어때 ” 앱 내의 WebView(Android/iOS) 환경에서 동시에 작동하는 서비스입니다.

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

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

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

1. 왜 Playwright를 선택했는가

GUI E2E Test Tool 중의 양대산맥인 Cypress 와 Playwright 가 있습니다.

각자의 장점과 단점을 알아보고 왜 Playwright를 선택하게 되었는지 같이 체크 해보겠습니다.

Cypress 장점

Cypress 단점

Playwright 장점

Playwright 단점

선택 기준과 결론

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

2. 목표와 범위

E2E 테스트로 달성하려 한 것

검증하는 사용자 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.초기화

  1. 테스트 실행 + 인스트루멘테이션
  1. 리포트 생성

5. 파일별 역할

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>`
      );
    },
  };
}
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) {
  }
});
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);
}

요약

7. E2E coverage Test

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

비회원 유저의 접속

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

처음 진입하는 회원의 접속

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

중요 기능의 테스트를 완료하여 목표 Coverage 인 80%를 달성하였습니다.

각 항목의 설명을 하자면

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

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

8. 결론

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

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

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

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

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