개념
테스트가 실패한 이유가 "데이터가 없어서"일 때
E2E 테스트가 이런 이유로 실패하는 것을 자주 본다.
- "배송 중인 주문 상세 화면" 테스트 — 스테이징에 배송 중 주문이 하나도 없다
- "쿠폰 적용 결제" 테스트 — 어제 실행에서 쿠폰을 이미 써 버렸다
- "환불 요청" 테스트 — 다른 팀원이 수동 테스트하다 그 주문을 취소했다
- "VIP 회원 전용 혜택" 테스트 — VIP 등급 계정의 등급이 월초 배치로 내려갔다
모두 제품 결함이 아니고, 테스트 코드 결함도 아니다. 테스트가 기대한 사전 조건(pre-condition) 이 환경에 없었을 뿐이다. 모듈 7 레슨 2에서 테스트 데이터 관리의 세 접근(합성 데이터·마스킹·시딩과 리셋)과 데이터 격리를 배웠다. 이 레슨은 그중 E2E 자동화에서 가장 실용적인 방식 — 테스트가 스스로 필요한 데이터를 API로 만들고 끝나면 치우는 구조 — 를 Playwright로 설계한다. 모듈 13 레슨 6의 "불안정한 테스트의 원인" 중 데이터·환경 범주를 구조적으로 없애는 방법이다.
전체 파이프라인 — 다섯 단계
┌──────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌──────────────┐ ┌────────────┐
│ 1. Pre-condition │ → │ 2. Fixture Call│ → │ 3. Context │ → │ 4. E2E 실행 │ → │ 5. Clean-up│
│ 추출 │ │ API로 상태 생성 │ │ Inject │ │ UI 시나리오 │ │ 정리·취소 │
│ TC 메타데이터의 │ │ 주문·쿠폰·계정 │ │ 생성된 ID·토큰 │ │ 화면 조작과 │ │ 생성한 것만 │
│ fixture_payload │ │ (100% API) │ │ 을 테스트에 전달│ │ 단언 │ │ 되돌림 │
└──────────────────┘ └────────────────┘ └────────────────┘ └──────────────┘ └────────────┘
│ │ │
│ └─ 실패(4xx/5xx) → [환경 문제] └─ 실패 → [셀렉터 오류] 또는 [실제 버그]| 단계 | 하는 일 | 설계 포인트 |
|---|---|---|
| 1. Pre-condition 추출 | 테스트 케이스가 필요로 하는 상태를 선언에서 읽는다 | 테스트 코드 안에 흩어두지 않고 메타데이터(fixture_payload) 로 |
| 2. Fixture Call | 백엔드 API로 필요한 상태를 만든다 | UI를 쓰지 않는다. 빠르고 결정적인 API만 |
| 3. Context Inject | 만든 리소스의 ID·토큰·URL을 테스트에 전달 | 테스트는 "어떤 주문"이 아니라 "이 주문"을 다룬다 |
| 4. E2E 실행 | 시험하려는 화면 흐름만 UI로 수행 | 준비 과정이 빠져 테스트가 짧고 목적이 선명해진다 |
| 5. Clean-up | 테스트가 만든 리소스만 정리·취소 | 실패해도 반드시 실행. 식별 접두사로 남은 것도 찾을 수 있게 |
화살표 아래 두 갈래가 트리아지의 출발점이다. 어느 단계에서 실패했는가 가 곧 실패의 성격을 알려준다(마지막 절).
Playwright Custom Fixture — test.extend
Playwright의 test.extend 는 테스트에 주입할 fixture 를 정의한다. fixture 함수는 use() 호출 앞에서 준비 하고, use() 로 값을 테스트에 넘기고, use() 가 끝난 뒤에서 정리 한다.
테스트가 실패해도 정리 코드는 실행된다. 이 구조가 위 파이프라인의 2·3·5단계와 정확히 대응한다.
먼저 API 클라이언트를 만든다. Playwright의 request fixture(APIRequestContext)를 쓰면 별도 HTTP 라이브러리 없이 같은 설정(baseURL·헤더)으로 API를 부를 수 있다.
// fixtures/api-client.ts
import { APIRequestContext, APIResponse } from '@playwright/test';
export class FixtureApiError extends Error {
constructor(
readonly step: string,
readonly status: number,
readonly body: string,
) {
super(`[fixture:${step}] HTTP ${status} — ${body.slice(0, 300)}`);
this.name = 'FixtureApiError';
}
}
async function ensureOk(step: string, res: APIResponse): Promise<APIResponse> {
if (!res.ok()) throw new FixtureApiError(step, res.status(), await res.text());
return res;
}
export interface CreateOrderPayload {
productSku: string;
quantity: number;
status: 'PAID' | 'SHIPPING' | 'DELIVERED';
couponCode?: string;
}
export interface CreatedOrder {
orderId: string;
orderNo: string;
status: string;
}
export class FixtureApi {
constructor(
private readonly api: APIRequestContext,
private readonly runTag: string, // 멱등성·정리용 식별 접두사
) {}
async createOrder(userId: string, payload: CreateOrderPayload): Promise<CreatedOrder> {
const res = await ensureOk('createOrder', await this.api.post('/qa-fixtures/orders', {
data: { userId, ...payload, externalRef: `${this.runTag}-order` },
headers: { 'Idempotency-Key': `${this.runTag}-order` },
}));
return res.json();
}
async cancelOrder(orderId: string): Promise<void> {
const res = await this.api.post(`/qa-fixtures/orders/${orderId}/cancel`);
// 이미 정리된 경우(404·409)는 정리 성공으로 본다 — 정리는 멱등해야 한다
if (!res.ok() && ![404, 409].includes(res.status())) {
await ensureOk('cancelOrder', res);
}
}
async cleanupByTag(): Promise<void> {
// 테스트가 중간에 죽어 개별 정리를 못 했을 때를 위한 안전망
await this.api.delete(`/qa-fixtures/resources?tagPrefix=${encodeURIComponent(this.runTag)}`);
}
}엔드포인트 경로 /qa-fixtures/... 는 예시다. 실제로는 제품의 공개 API를 그대로 쓰거나, 시험 환경에서만 열리는 fixture 전용 API(주문 상태를 바로 SHIPPING으로 만드는 등)를
개발팀과 합의해 만든다. 후자가 훨씬 빠르고 결정적이지만, 운영 환경에서는 절대 열리지 않게 배포 설정으로 막아야 한다(마지막 흔한 실수 참조).
이제 fixture를 정의한다.
// fixtures/test.ts
import { test as base, expect, request as pwRequest, APIRequestContext } from '@playwright/test';
import { FixtureApi, CreatedOrder, CreateOrderPayload, FixtureApiError } from './api-client';
type FixturePayload = {
user: 'buyer' | 'vip';
order?: CreateOrderPayload;
};
type TestOptions = {
fixturePayload: FixturePayload; // 테스트마다 test.use() 로 지정 — TC 메타데이터에서 옴
};
type TestFixtures = {
runTag: string;
fixtureApi: FixtureApi;
preparedOrder: CreatedOrder;
};
type WorkerFixtures = {
serviceApi: APIRequestContext; // 서비스별 API 토큰을 미리 주입한 컨텍스트
};
export const test = base.extend<TestOptions & TestFixtures, WorkerFixtures>({
fixturePayload: [{ user: 'buyer' }, { option: true }],
// 워커당 한 번: 서비스 API 토큰을 헤더에 주입 (로그인 UI 우회)
serviceApi: [async ({}, use) => {
const ctx = await pwRequest.newContext({
baseURL: process.env.API_BASE_URL,
extraHTTPHeaders: { Authorization: `Bearer ${process.env.QA_SERVICE_TOKEN}` },
});
await use(ctx);
await ctx.dispose();
}, { scope: 'worker' }],
// 테스트마다 고유 식별 접두사 — 병렬 실행·재실행에서도 충돌하지 않게
runTag: async ({}, use, testInfo) => {
const runId = process.env.CI_RUN_ID ?? 'local';
await use(`qa-e2e-${runId}-${testInfo.workerIndex}-${testInfo.testId}-r${testInfo.retry}`);
},
fixtureApi: async ({ serviceApi, runTag }, use) => {
const api = new FixtureApi(serviceApi, runTag);
await use(api);
await api.cleanupByTag(); // 5) 안전망 정리
},
// 2) Fixture Call → 3) Context Inject → 5) Clean-up
preparedOrder: async ({ fixtureApi, fixturePayload }, use, testInfo) => {
if (!fixturePayload.order) throw new Error('fixture_payload.order 가 정의되지 않았습니다');
testInfo.annotations.push({ type: 'phase', description: 'fixture' });
const userId = testInfo.project.metadata?.users?.[fixturePayload.user] ?? fixturePayload.user;
const order = await fixtureApi.createOrder(userId, fixturePayload.order);
testInfo.annotations.push({ type: 'phase', description: 'ui' });
await use(order); // 4) 테스트 본문 실행
await fixtureApi.cancelOrder(order.orderId);
},
});
export { expect, FixtureApiError };storageState 로 로그인 상태를 미리 만드는 것도 같은 원칙이다. 브라우저 테스트가 로그인 화면을 거치지 않도록, 사용자별 토큰·쿠키를 API로 발급받아 저장 상태로 주입 한다.
// global-setup.ts — 사용자별 로그인 상태를 API 로 만든다 (로그인 UI 를 타지 않음)
import { request, FullConfig } from '@playwright/test';
export default async function globalSetup(config: FullConfig) {
for (const user of ['buyer', 'vip'] as const) {
const ctx = await request.newContext({ baseURL: process.env.API_BASE_URL });
const res = await ctx.post('/auth/test-token', {
data: { account: process.env[`QA_${user.toUpperCase()}_ACCOUNT`] },
headers: { Authorization: `Bearer ${process.env.QA_SERVICE_TOKEN}` },
});
if (!res.ok()) throw new Error(`[fixture:auth] HTTP ${res.status()}`);
await ctx.storageState({ path: `.auth/${user}.json` }); // 발급된 쿠키를 저장
await ctx.dispose();
}
}테스트는 이렇게 짧아진다.
// tests/order-shipping.spec.ts
import { test, expect } from '../fixtures/test';
test.use({
storageState: '.auth/buyer.json',
fixturePayload: {
user: 'buyer',
order: { productSku: 'SKU-QA-001', quantity: 2, status: 'SHIPPING' },
},
});
test('배송 중 주문은 상세 화면에서 배송 조회 버튼이 보인다', async ({ page, preparedOrder }) => {
await page.goto(`/orders/${preparedOrder.orderId}`);
await expect(page.getByRole('heading', { name: preparedOrder.orderNo })).toBeVisible();
await expect(page.getByRole('button', { name: '배송 조회' })).toBeVisible();
await expect(page.getByRole('button', { name: '주문 취소' })).toBeHidden();
});테스트 본문에는 시험하려는 것만 남았다. 주문을 만들고 결제하고 배송 상태로 바꾸는 과정은 없다. 그 과정은 다른 테스트(또는 API 테스트)의 몫이다.
설계 원칙 ① — UI로 전처리 데이터를 만들지 않는다 (100% API)
전처리를 UI로 하면 세 가지가 무너진다.
| 문제 | 설명 |
|---|---|
| 속도 | 주문 하나를 만들려고 상품 검색·장바구니·결제 화면을 지나면 테스트 하나가 수십 초 늘어난다 |
| 실패 원인 오염 | "배송 조회 버튼" 테스트가 결제 화면 셀렉터 변경 때문에 실패한다. 시험 대상이 아닌 곳의 변경이 시험을 막는다 |
| 불안정성 | UI 단계가 많을수록 플래키 확률이 곱해진다 (모듈 13 레슨 6) |
그래서 원칙은 사전 데이터는 100% API로 다. UI는 시험 대상 흐름에만 쓴다. 모듈 11 레슨 1의 자동화 피라미드에서 E2E를 적게 두라는 원칙이 한 테스트 안에서도 적용되는 셈이다.
API가 없어서 UI로 만들 수밖에 없는 데이터가 있다면, 그것 자체를 개발팀에 요청할 테스트 용이성(testability) 항목 으로 기록한다.
설계 원칙 ② — fixture_payload로 파라미터를 중앙 관리한다
필요한 사전 조건을 테스트 코드마다 하드코딩하면, 상품 SKU가 바뀌거나 쿠폰 정책이 바뀔 때 수십 개 파일을 고쳐야 한다. 테스트 케이스 메타데이터(테스트 관리 도구의 사용자 정의 필드,
또는 저장소의 TC 정의 파일)에 fixture_payload 필드를 두고 한 곳에서 관리한다.
# testcases/order/TC-ORD-021.yaml
id: TC-ORD-021
title: 배송 중 주문은 상세 화면에서 배송 조회 버튼이 보인다
priority: high
automation:
spec: tests/order-shipping.spec.ts
fixture_payload:
user: buyer
order:
productSku: SKU-QA-001 # QA 전용 상품 — 재고 무제한, 가격 고정
quantity: 2
status: SHIPPING테스트는 메타데이터를 읽어 test.use() 에 넣는다.
// fixtures/load-payload.ts
import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
export function fixturePayloadOf(tcId: string) {
const [area] = tcId.replace('TC-', '').split('-');
const doc = parse(readFileSync(`testcases/${area.toLowerCase()}/${tcId}.yaml`, 'utf8'));
if (!doc.fixture_payload) throw new Error(`${tcId}: fixture_payload 없음`);
return doc.fixture_payload;
}
// tests/order-shipping.spec.ts 에서
// test.use({ storageState: '.auth/buyer.json', fixturePayload: fixturePayloadOf('TC-ORD-021') });얻는 것은 세 가지다. 정책 변경을 한 곳에서 반영하고, 테스트 관리 도구에서 "이 TC가 어떤 데이터를 필요로 하는가"를 코드 없이 읽을 수 있고, 같은 payload를 수동 테스트 준비 스크립트 로도 재사용할 수 있다.
설계 원칙 ③ — 식별 접두사로 멱등성을 보장한다
E2E는 병렬로 돌고, 실패하면 재시도하고, 같은 커밋으로 여러 번 돈다. 그때마다 데이터가 겹치면 테스트끼리 서로의 주문을 취소하거나, 같은 쿠폰을 동시에 쓰거나, 정리되지 않은 데이터가 쌓인다.
runTag 가 이 문제를 푼다.
qa-e2e-{CI 실행 ID}-{워커 번호}-{테스트 ID}-r{재시도 횟수}
예: qa-e2e-8812345-3-a1b2c3d4-r0| 쓰임 | 효과 |
|---|---|
생성 요청의 Idempotency-Key | 네트워크 재시도로 같은 요청이 두 번 가도 주문은 하나 (모듈 41 레슨 2의 멱등성) |
리소스의 외부 참조(externalRef) | 누가 만든 데이터인지 운영자가 즉시 알아봄 |
| 병렬 실행 격리 | 워커·테스트·재시도마다 달라 서로 충돌하지 않음 |
| 안전망 정리 | 테스트가 비정상 종료해도 접두사로 남은 리소스를 일괄 삭제 |
| 야간 청소 | qa-e2e- 로 시작하고 24시간 지난 리소스를 배치로 정리 |
재시도 번호(r0, r1)를 넣은 이유가 있다. 첫 시도에서 정리에 실패한 리소스가 남아 있어도 재시도는 새 리소스 로 시작한다. 재시도가 첫 시도의 잔여물을 재사용하면, 첫 시도의
실패 원인이 재시도에 그대로 이어진다.
설계 원칙 ④ — 서비스별 API 토큰을 미리 주입해 로그인을 우회한다
모든 테스트가 로그인 화면을 거치면 로그인 화면 하나의 변경이 전체 스위트 를 멈춘다. 로그인은 로그인 테스트에서만 UI로 시험하고, 나머지는 토큰을 주입한다.
| 대상 | 방식 |
|---|---|
| Fixture API 호출 | 워커 범위 serviceApi 에 QA 서비스 토큰을 헤더로 주입 (위 코드) |
| 브라우저 세션 | globalSetup 에서 API로 사용자 세션을 발급받아 storageState 로 저장, test.use({ storageState }) 로 주입 |
| 마이크로서비스별 토큰 | 서비스마다 인증 체계가 다르면 서비스별 APIRequestContext 를 워커 fixture로 분리 |
토큰은 CI 비밀 저장소 에서 환경 변수로 주입하고 저장소에 커밋하지 않는다. .auth/*.json 도 .gitignore 에 넣는다. QA 서비스 토큰은 시험 환경 전용 권한 으로 발급받고,
운영 환경에서는 유효하지 않게 한다.
QMS·트리아지 연동 — 실패 단계로 자동 분류한다
파이프라인을 단계로 나눈 보상이 여기서 나온다. 테스트가 어느 단계에서 실패했는지 알면, 실패의 성격을 사람이 로그를 읽기 전에 자동으로 1차 분류할 수 있다.
| 실패 단계 | 신호 | 자동 분류 | 다음 동작 |
|---|---|---|---|
| Fixture 단계 (API 4xx/5xx, 토큰 발급 실패) | FixtureApiError 또는 phase=fixture 에서 실패 | [환경 문제] | 환경 담당·당번 알림, 결함 등록하지 않음, 재실행 대상 |
| UI 단계 — 요소를 찾지 못함 | 로케이터 대기 타임아웃, strict mode 위반 | [셀렉터 오류] | 자동화 담당 검토 (셀프힐링 대상 여부 — 모듈 18a 레슨 15) |
| UI 단계 — 요소는 찾았으나 기대와 다름 | expect 단언 실패 | [실제 버그] 후보 | 결함 초안 생성, 사람 확인 후 등록 |
| Clean-up 단계 | 정리 API 실패 | [환경 문제] (테스트 결과는 유지) | 정리 실패 목록 적재, 야간 청소 대상 |
FixtureApiError 를 따로 만든 이유가 이것이다. 에러 이름만으로 단계가 구분된다. 분류는 커스텀 리포터 로 구현한다.
// reporters/triage-reporter.ts
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
type Triage = '환경 문제' | '셀렉터 오류' | '실제 버그' | '미분류';
export function classify(result: TestResult): Triage {
const err = result.errors[0];
const msg = `${err?.message ?? ''}\n${err?.stack ?? ''}`;
const lastPhase = [...result.annotations].reverse().find((a) => a.type === 'phase')?.description;
// 1) Fixture 단계 실패 — API 4xx/5xx 또는 fixture 단계에서 멈춤
if (/FixtureApiError|\[fixture:/.test(msg) || lastPhase === 'fixture') return '환경 문제';
// 2) UI 단계 — 요소를 찾지 못함
if (/strict mode violation/.test(msg)) return '셀렉터 오류';
if (/Timeout \d+ms exceeded/.test(msg) && /waiting for (locator|getBy)/.test(msg)) return '셀렉터 오류';
// 3) UI 단계 — 요소는 있으나 기대와 다름
if (/expect\(.*\)\.to|toHave|toBe|toContain/.test(msg)) return '실제 버그';
return '미분류';
}
export default class TriageReporter implements Reporter {
private rows: { title: string; triage: Triage; message: string }[] = [];
onTestEnd(test: TestCase, result: TestResult) {
if (result.status === 'passed' || result.status === 'skipped') return;
this.rows.push({
title: test.titlePath().join(' > '),
triage: classify(result),
message: (result.errors[0]?.message ?? '').split('\n')[0].slice(0, 200),
});
}
async onEnd() {
// 여기서 QMS·결함 추적 도구 API 로 전송하거나 파일로 저장한다
// '실제 버그' 는 결함 "초안"으로만 만든다 — 등록은 사람이 확인한 뒤
console.table(this.rows);
}
}// playwright.config.ts (발췌)
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
reporter: [['html'], ['./reporters/triage-reporter.ts']],
use: { baseURL: process.env.WEB_BASE_URL },
});이 분류는 1차 분류 다. 세 가지를 기억하자.
- [실제 버그] 는 후보일 뿐이다. 단언 실패는 기대값이 틀린 테스트 결함일 수도 있다. 결함은 초안으로 만들고 사람이 등록한다(모듈 38 레슨 5 — 자동 등록은 결함 추세를 오염시킨다)
- [환경 문제] 가 반복되면 그것도 결함이다. 같은 fixture API가 매일 500을 내면 시험 환경 결함이고, 개발팀 과제다. 환경 문제 비율을 지표로 본다(모듈 38 레슨 6)
- 분류 규칙은 Playwright 메시지 형식에 의존한다. 버전을 올릴 때 분류기 자체의 회귀 시험(각 분류를 일부러 만드는 테스트)을 돌린다
실무에서 왜 필요한가
E2E 스위트가 커질수록 실패의 상당 부분이 제품 결함이 아니라 데이터와 환경 에서 나온다. 매일 아침 QA가 실패 목록을 열어 "이건 데이터 문제, 이건 환경, 이건 진짜"를 손으로 가르는 동안, 진짜 결함은 소음 속에 묻힌다. 그리고 팀은 E2E 결과를 믿지 않게 된다.
API fixture와 단계별 트리아지는 이 두 문제를 같이 푼다. 데이터 부재로 인한 실패가 구조적으로 줄고, 남은 실패는 어느 단계에서 났는지로 자동 1차 분류 된다. 모듈 38의 AI 검증 파이프라인도 이 구조 위에서만 작동한다. 판정자가 "데이터가 없어서 실패한 테스트"를 결함으로 판정하면 게이트 전체를 믿을 수 없기 때문이다. 채용 공고의 "E2E·API 테스트 자동화를 구축·운영해 본 경험"에서 운영 이 가리키는 것이 바로 이 부분이다.