Contents
see List왜 E2E 테스트는 자주 흔들리는가
브라우저 자동화 테스트는 실제 사용자 흐름을 가장 가깝게 검증할 수 있지만, 동시에 가장 쉽게 흔들리는 테스트이기도 합니다. 버튼이 아직 렌더링되지 않았는데 클릭하거나, 네트워크 응답이 느린 날에만 실패하거나, 화면 문구가 조금 바뀌었는데 선택자가 깨지는 일이 반복됩니다. 이런 실패가 쌓이면 팀은 테스트를 신뢰하지 못하고, 결국 중요한 배포 전 검증 단계가 형식적인 절차가 됩니다.
Playwright를 안정적으로 운영하려면 단순히 timeout을 늘리는 방식보다 테스트가 기다려야 할 상태를 명확히 표현해야 합니다. 요소를 찾는 기준, 로그인과 데이터 준비 방식, 실패 시 남길 증거, 재시도 기준을 함께 설계해야 합니다. 이 문서는 실무 프로젝트에서 flaky 테스트를 줄이기 위한 Playwright 설정과 작성 기준을 정리합니다.
locator는 사용자 기준으로 작성한다
가장 먼저 점검할 부분은 선택자입니다. CSS 클래스나 DOM 깊이에 의존한 선택자는 화면 구조가 조금만 바뀌어도 깨집니다. 반대로 role, label, placeholder, text처럼 사용자가 인식하는 정보에 가까운 locator는 UI 리팩터링에도 비교적 강합니다. 테스트가 사용자 관점의 계약을 검증하도록 만드는 것이 핵심입니다.
- 버튼, 링크, 탭은 getByRole을 우선 사용합니다.
- 입력 필드는 getByLabel 또는 getByPlaceholder를 사용합니다.
- 반복 항목은 전체 페이지에서 찾지 말고 특정 카드나 행 안으로 범위를 좁힙니다.
- 테스트 전용 data-testid는 접근 가능한 이름으로 표현하기 어려운 복합 UI에 제한적으로 사용합니다.
import { test, expect } from '@playwright/test';
test('관리자가 상품 가격을 수정한다', async ({ page }) => {
await page.goto('/admin/products');
const row = page.getByRole('row', { name: /무선 키보드/ });
await row.getByRole('button', { name: '수정' }).click();
await page.getByLabel('판매가').fill('59000');
await page.getByRole('button', { name: '저장' }).click();
await expect(page.getByText('상품 정보가 저장되었습니다')).toBeVisible();
await expect(row.getByText('59,000원')).toBeVisible();
});
위 예시는 DOM 구조가 아니라 관리자가 실제로 보는 행, 버튼, 라벨, 저장 메시지를 기준으로 테스트합니다. 화면 구조를 바꿔도 사용자에게 보이는 의미가 유지된다면 테스트도 유지될 가능성이 높습니다.
기다림은 timeout이 아니라 상태로 표현한다
flaky 테스트의 흔한 원인은 임의 대기입니다. waitForTimeout(1000)처럼 시간을 박아두면 빠른 환경에서는 불필요하게 느리고, 느린 환경에서는 여전히 실패합니다. Playwright는 locator 액션과 expect에서 자동 대기를 제공하므로, 대부분의 경우 특정 시간이 아니라 특정 상태를 기다리면 됩니다.
- 요소가 나타나야 한다면 toBeVisible을 사용합니다.
- 비동기 저장 완료는 응답 코드나 성공 메시지로 확인합니다.
- 로딩 표시가 사라져야 한다면 toBeHidden 또는 toHaveCount(0)을 사용합니다.
- 페이지 이동은 URL, heading, 핵심 콘텐츠 중 하나로 확인합니다.
await page.getByRole('button', { name: '주문 생성' }).click();
await expect(page.getByRole('status', { name: '저장 중' })).toBeHidden();
await expect(page).toHaveURL(/\/orders\/\d+$/);
await expect(page.getByRole('heading', { name: '주문 상세' })).toBeVisible();
테스트가 기다리는 조건을 이렇게 드러내면 실패 원인도 명확해집니다. 저장 중 상태가 사라지지 않았는지, URL 이동이 안 됐는지, 상세 화면 제목이 누락됐는지를 따로 볼 수 있습니다.
테스트 데이터는 격리하고 반복 가능하게 만든다
E2E 테스트는 데이터 충돌에도 약합니다. 이미 같은 이메일 사용자가 있어서 회원 가입 테스트가 실패하거나, 이전 테스트가 만든 주문이 목록에 남아 다음 테스트의 첫 번째 행을 바꿔버릴 수 있습니다. 테스트 데이터는 실행마다 고유하게 만들고, 가능하면 API나 DB seed로 빠르게 준비하는 편이 안정적입니다.
UI로 모든 준비를 수행하면 테스트가 길어지고 실패 지점이 늘어납니다. 예를 들어 주문 상세 화면을 검증하려면 로그인, 상품 등록, 장바구니 담기, 결제 흐름을 매번 UI로 통과할 필요가 없습니다. 그 흐름 자체를 검증하는 테스트는 따로 두고, 주문 상세 테스트는 API로 주문을 만든 뒤 상세 화면만 검증하는 식으로 범위를 줄일 수 있습니다.
test.beforeEach(async ({ request, page }) => {
const email = 'admin-' + Date.now() + '@example.test';
await request.post('/test-api/users', {
data: { email, role: 'ADMIN' }
});
await page.goto('/login');
await page.getByLabel('이메일').fill(email);
await page.getByLabel('비밀번호').fill('test-password');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByRole('navigation')).toBeVisible();
});
테스트 전용 API는 운영 환경에 노출하면 안 됩니다. 로컬, CI, 스테이징 테스트 환경에서만 활성화하고 인증 또는 네트워크 제한을 둬야 합니다. 중요한 점은 테스트가 어떤 데이터를 전제로 하는지 코드에 드러나야 한다는 것입니다.
playwright.config에서 실패 증거를 남긴다
테스트가 실패했을 때 가장 비용이 큰 일은 재현입니다. 개발자 PC에서는 통과하고 CI에서만 실패하면 원인을 찾기 어렵습니다. 따라서 CI에서는 trace, screenshot, video 같은 증거를 남겨야 합니다. 모든 실행에서 영상을 남기면 저장 공간과 실행 시간이 커지므로, 실패했을 때만 남기는 설정이 보통 적당합니다.
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: process.env.CI ? [['html'], ['github']] : [['list']],
use: {
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
],
webServer: {
command: 'npm run start:test',
url: 'http://127.0.0.1:3000/health',
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
});
이 설정은 CI에서 실패한 테스트를 최대 2회 재시도하고, 첫 재시도에는 trace를 남깁니다. trace 파일은 어떤 locator가 어떤 시점에 실패했는지, 네트워크 요청과 콘솔 오류가 무엇이었는지 확인하는 데 유용합니다. retries는 실패를 숨기는 장치가 아니라 일시적 흔들림을 증거와 함께 다시 관찰하는 장치로 사용해야 합니다.
테스트를 너무 큰 시나리오로 만들지 않는다
하나의 테스트가 로그인, 상품 검색, 장바구니, 쿠폰, 결제, 관리자 확인까지 모두 포함하면 실패 원인을 좁히기 어렵습니다. 핵심 사용자 여정 하나를 검증하는 smoke 테스트는 필요하지만, 모든 케이스를 긴 시나리오로 만들면 유지보수 비용이 급격히 커집니다. 테스트는 실패했을 때 무엇이 깨졌는지 이름만 보고도 어느 정도 짐작할 수 있어야 합니다.
- 기능별 테스트는 한 화면 또는 한 업무 단위로 작게 유지합니다.
- 공통 로그인은 storageState로 재사용하되, 권한별 상태를 분리합니다.
- 결제, 외부 API, 이메일 발송처럼 변수가 큰 영역은 mock 또는 테스트 전용 sandbox를 사용합니다.
- 테스트 이름은 기대 결과까지 포함해 구체적으로 작성합니다.
// 예: 로그인 상태를 파일로 저장한 뒤 테스트 프로젝트에서 재사용
// npx playwright test tests/e2e/setup.auth.ts
import { test as setup, expect } from '@playwright/test';
setup('관리자 로그인 상태 저장', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('[email protected]');
await page.getByLabel('비밀번호').fill('test-password');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByRole('navigation')).toBeVisible();
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});
저장된 인증 상태를 사용하면 매 테스트마다 로그인 UI를 통과하지 않아도 됩니다. 다만 로그인 기능 자체를 검증하는 테스트는 별도로 유지해야 합니다. 인증 상태 파일은 민감한 토큰을 포함할 수 있으므로 저장소에 커밋하지 않고 CI에서는 매 실행마다 생성하는 편이 안전합니다.
CI에서는 병렬성과 격리를 같이 본다
Playwright는 병렬 실행으로 테스트 시간을 줄일 수 있지만, 공유 데이터가 있는 상태에서 병렬성을 높이면 실패가 늘어날 수 있습니다. CI에서 workers를 무작정 늘리기보다 테스트 데이터 충돌 가능성, 테스트 서버의 처리량, 데이터베이스 초기화 방식을 함께 봐야 합니다. 병렬 실행이 필요한 경우 테스트마다 고유 tenant, 고유 계정, 고유 주문 번호를 사용하도록 설계합니다.
테스트가 간헐적으로 실패하면 실패한 spec만 반복 실행해 패턴을 확인하는 것도 좋습니다. 같은 테스트를 20회 반복했을 때 실패한다면 코드나 테스트 설계의 문제일 가능성이 높고, 특정 CI 노드에서만 실패한다면 환경 차이를 의심해야 합니다.
# 특정 테스트 파일을 반복 실행해 흔들림 확인
npx playwright test tests/e2e/order.spec.ts --repeat-each=20 --workers=1
# 실패한 테스트의 trace 확인
npx playwright show-trace test-results/order-create-retry1/trace.zip
운영 체크리스트
- 선택자는 CSS 구조보다 role, label, text 같은 사용자 기준을 우선한다.
- waitForTimeout 대신 expect와 locator 자동 대기로 상태를 기다린다.
- 테스트 데이터는 실행마다 고유하게 만들고, 필요한 준비는 API나 seed로 빠르게 처리한다.
- CI에서는 trace, screenshot, video를 실패 분석용으로 남긴다.
- retries는 실패를 덮는 용도가 아니라 일시 실패를 관찰하고 증거를 확보하는 용도로 사용한다.
- 긴 시나리오는 smoke 테스트로 제한하고, 대부분의 검증은 작고 이름이 명확한 테스트로 나눈다.
- 병렬 실행 전에는 데이터 격리, 인증 상태, 외부 API mock 여부를 먼저 점검한다.