Contents
see ListCI에서만 실패하는 E2E 테스트를 다루는 방식
Playwright 같은 브라우저 자동화 도구를 운영 환경에 가까운 검증 단계로 쓰면 배포 전 화면 오류, 인증 흐름 문제, API 응답 지연, 반응형 레이아웃 깨짐을 빨리 잡을 수 있습니다. 하지만 E2E 테스트는 단위 테스트보다 외부 조건을 많이 탑니다. 로컬에서는 통과했는데 CI에서만 실패하거나, 같은 테스트가 어떤 날은 통과하고 어떤 날은 실패하는 일이 생깁니다. 이때 가장 중요한 것은 실패를 감으로 고치는 것이 아니라 실패 당시의 브라우저 상태를 증거로 남기고 재현 가능한 순서로 좁히는 것입니다.
운영 관점에서 Playwright를 도입할 때는 테스트 코드보다 먼저 실패 분석 체계를 설계해야 합니다. 트레이스, 스크린샷, 비디오, 콘솔 로그, 네트워크 로그를 언제 남길지 정하고, CI 산출물로 보관해 개발자가 링크 하나로 확인할 수 있게 만들어야 합니다. 모든 실행마다 영상을 남기면 저장소와 CI 시간이 낭비되고, 아무것도 남기지 않으면 장애 원인을 다시 추측해야 합니다. 보통은 첫 실행에는 가볍게 돌리고, 재시도에서 실패할 때 트레이스와 스크린샷을 남기는 구성이 실용적입니다.
기본 설정: 실패한 순간을 남기는 playwright.config.ts
아래 예시는 CI에서 재시도를 켜고, 실패한 테스트의 트레이스를 남기며, HTML 리포트를 산출물로 저장하기 위한 기본 설정입니다. 핵심은 timeout, retries, workers, trace, screenshot, video를 팀의 서비스 특성에 맞게 명시하는 것입니다. 특히 workers를 무조건 크게 잡으면 인증 세션, 테스트 데이터, 서버 부하가 서로 충돌해 불안정성이 커질 수 있습니다.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: { timeout: 5_000 },
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }]
],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 15_000
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } }
]
});
이 설정은 평소 실행 비용을 낮추면서도 재시도 실패 시 충분한 분석 자료를 남깁니다. trace가 on-first-retry이면 첫 실패 후 재시도 과정이 기록됩니다. flaky 테스트를 찾는 데 특히 유용합니다. screenshot은 최종 화면 상태를 빠르게 확인할 때 좋고, video는 사용자가 어떤 순서로 이동했는지 확인할 때 도움이 됩니다. 단, 비디오는 저장 공간을 많이 쓰므로 retain-on-failure 정도가 적당합니다.
GitHub Actions에서 리포트와 트레이스 보관하기
CI에서 리포트 파일을 만들었더라도 저장하지 않으면 실행 종료와 함께 사라집니다. 테스트가 실패했을 때 개발자가 바로 내려받아 확인할 수 있도록 artifacts에 playwright-report와 test-results를 보관해야 합니다. 아래 예시는 Node.js 프로젝트 기준의 최소 워크플로입니다.
name: e2e
on:
pull_request:
workflow_dispatch:
jobs:
playwright:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run start:test &
- run: npx wait-on http://127.0.0.1:3000
- run: npx playwright test
env:
CI: true
BASE_URL: http://127.0.0.1:3000
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-artifacts
path: |
playwright-report
test-results
retention-days: 7
여기서 if: always()가 중요합니다. 테스트가 실패해도 리포트 업로드 단계는 반드시 실행되어야 합니다. 또한 애플리케이션 서버를 백그라운드로 띄운 뒤 바로 테스트를 시작하지 말고 wait-on 같은 준비 확인 단계를 둬야 합니다. 서버가 아직 뜨지 않았는데 테스트가 먼저 접근하면 실제 제품 문제가 아닌 CI 순서 문제로 실패합니다.
테스트 코드 작성 원칙
불안정한 E2E 테스트의 많은 원인은 화면을 사람처럼 기다리지 않고 DOM 상태만 성급하게 확인하는 데서 나옵니다. Playwright의 locator와 expect는 자동 대기를 제공하므로 임의의 sleep을 넣기보다 사용자가 실제로 볼 수 있는 상태를 기준으로 기다려야 합니다. 예를 들어 버튼 클릭 후 목록이 갱신되는 화면이라면 네트워크 시간보다 결과 행의 표시 여부를 검증하는 편이 안정적입니다.
import { test, expect } from '@playwright/test';
test('관리자가 주문 상태를 발송 완료로 변경한다', async ({ page }) => {
await page.goto('/admin/orders');
await page.getByRole('textbox', { name: '주문번호' }).fill('SOFT-2026-001');
await page.getByRole('button', { name: '검색' }).click();
const row = page.getByRole('row', { name: /SOFT-2026-001/ });
await expect(row).toBeVisible();
await row.getByRole('button', { name: '상태 변경' }).click();
await page.getByRole('option', { name: '발송 완료' }).click();
await page.getByRole('button', { name: '저장' }).click();
await expect(row.getByText('발송 완료')).toBeVisible();
});
선택자는 CSS 클래스보다 role, label, visible text를 우선하는 것이 좋습니다. CSS 클래스는 디자인 변경에 따라 자주 바뀌지만 접근성 이름은 사용자 경험과 직접 연결됩니다. 이 방식은 테스트 안정성뿐 아니라 접근성 품질을 함께 높입니다. 테스트를 쓰기 어려운 화면은 실제 사용자도 이해하기 어려운 화면일 가능성이 큽니다.
운영에서 자주 생기는 실패 원인
- 테스트 데이터가 공유되어 병렬 실행 중 서로의 상태를 바꾸는 경우
- 로그인 세션을 재사용하다가 권한이나 만료 시간이 테스트마다 달라지는 경우
- API mock과 실제 서버 응답 구조가 달라 배포 직전에서만 실패하는 경우
- 애니메이션이나 지연 로딩 요소를 기다리지 않고 바로 클릭하는 경우
- CI 서버 성능이 낮은데 timeout을 로컬 기준으로 짧게 잡은 경우
이 문제를 줄이려면 테스트마다 독립적인 데이터를 만들고, 테스트 완료 후 정리하거나 고유 접두어를 붙여 충돌을 피해야 합니다. 로그인은 프로젝트 단위 storageState로 최적화할 수 있지만, 권한별 상태 파일을 분리하고 만료 조건을 관리해야 합니다. 외부 결제, 문자 발송, 지도 API처럼 제어하기 어려운 의존성은 운영 테스트와 격리 테스트를 나눠야 합니다.
트레이스를 보는 실전 순서
실패한 테스트를 받으면 먼저 HTML 리포트에서 실패한 단계와 에러 메시지를 확인합니다. 다음으로 trace viewer를 열어 액션 전후 스냅샷, 콘솔 에러, 네트워크 요청을 봅니다. 버튼은 클릭됐는데 API가 500을 반환했는지, API는 성공했는데 화면 갱신이 누락됐는지, 아예 다른 모달이 화면을 가리고 있었는지에 따라 수정 위치가 달라집니다.
npx playwright show-report playwright-report
npx playwright show-trace test-results/**/trace.zip
반복 실패와 일시 실패를 구분하는 것도 중요합니다. 같은 테스트가 같은 단계에서 계속 실패하면 제품 코드나 테스트 코드의 명확한 결함일 가능성이 큽니다. 반대로 실패 위치가 매번 바뀐다면 서버 준비 상태, 병렬 데이터 충돌, 과도한 worker 수, 외부 API 지연을 먼저 의심해야 합니다. 이때 재시도 횟수를 늘리는 것은 원인 해결이 아니라 증상 숨기기가 될 수 있습니다.
도입 체크리스트
- CI에서는 trace: on-first-retry, screenshot: only-on-failure, video: retain-on-failure로 시작한다.
- playwright-report와 test-results는 실패 여부와 관계없이 artifact로 보관한다.
- 서버 시작 후 health check 또는 wait-on으로 준비 완료를 확인한 뒤 테스트를 실행한다.
- 선택자는 CSS 클래스보다 role, label, text 기반 locator를 우선한다.
- 테스트 데이터는 병렬 실행에서도 충돌하지 않게 고유하게 만든다.
- flaky 테스트는 재시도 증가보다 트레이스 분석, 데이터 격리, 대기 조건 수정으로 해결한다.