개념
5분 진단이라는 기준
자동화가 신뢰를 잃는 순간은 테스트가 깨질 때가 아니라 깨진 이유를 알아내는 데 30분이 걸릴 때 다. 그때부터 사람들은 빨간 불을 보고도 열지 않는다. 그래서 리포트 설계의 목표를 숫자로 못 박는다 — 실패 하나를 열어 5분 안에 다음 네 질문에 답할 수 있는가.
- 무엇이 실패했는가 — 어떤 사용자 시나리오의 어떤 단계인가
- 어디서 멈췄는가 — 어느 단언, 어느 요소, 어느 요청
- 누구 책임인가 — 제품 결함 / 테스트 결함 / 환경 문제 / 데이터 문제
- 재현되는가 — 같은 커밋·같은 환경에서 다시 터지는가
레슨 2에서 세운 저장소는 이 네 질문의 재료를 모으는 쪽 을 맡는다. 매일 도는 실행을 운영하고 추세를 보는 일은 모듈 59, 파이프라인에서 결과를 모으고 게이트를 거는 일은 모듈 14 레슨 4·6 이다. 결함의 증거로 서버 로그를 읽는 일반 기법은 모듈 61 레슨 4, 운영 모니터링은 모듈 37 레슨 9 에 있다. 여기는 자동화 실행이 자기 실패를 설명하게 만드는 설계 로 좁힌다.
실행 아티팩트 — 비용과 가치를 따져 고른다
아티팩트는 많이 모을수록 좋은 게 아니다. 용량과 실행 시간을 먹고, 민감 정보를 품으며, 보관 기간이 길어지면 돈이 된다.
| 아티팩트 | 대략 크기 | 주는 답 | 권장 수집 조건 |
|---|---|---|---|
| 스크린샷 | 수백 KB | "화면이 어떤 상태였나" | 실패 시에만 |
| 비디오 | 수 MB | "어떤 순서로 일어났나" | 실패 시 보존 |
| 트레이스 | 수~수십 MB | 위 전부 + DOM 스냅샷 · 네트워크 · 콘솔 | 첫 재시도에만 |
| HAR | 수 MB | "요청·응답 본문이 정확히 무엇이었나" | API 의심 케이스만 |
| 콘솔·페이지 오류 | KB | "프런트에서 예외가 났나" | 항상 (값이 싸다) |
트레이스가 사실상 상위 집합이다. Playwright 트레이스에는 단계별 DOM 스냅샷, 네트워크 기록, 콘솔, 액션 타임라인이 함께 들어가므로, 트레이스를 켤 수 있다면 스크린샷· 비디오를 따로 켤 이유는 줄어든다. 그런데도 셋을 같이 쓰는 이유는 열어 보는 비용이 다르기 때문 이다 — 스크린샷은 리포트에서 바로 보이고, 트레이스는 뷰어를 띄워야 한다.
// playwright.config.ts (발췌) — 수집 조건은 전부 여기서 결정한다
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry', // 모든 실행에서 켜면 실행 시간과 용량이 급증한다
},trace: 'on-first-retry' 가 기본값으로 쓰이는 이유는 타협점이기 때문이다. 처음 돌 때는
추적을 끄고(빠르게), 실패해서 재시도할 때만 켠다. 다만 한 번만 터지고 재시도에서
통과하는 실패 는 이 설정으로 추적이 남지 않을 수 있다 — 플래키 추적이 목적이라면
retain-on-failure 쪽을 고려한다. 어느 쪽이든 결정과 근거를 설정 파일 주석에 남긴다.
HAR 는 기본으로 켜지 않는다. 요청·응답 본문을 통째로 담으므로 용량이 크고 토큰·
개인정보가 그대로 들어간다. API 응답을 의심하는 특정 스펙에서만 브라우저 컨텍스트의
recordHar 옵션으로 켠다(본문 포함 수준·마스킹 옵션은 Playwright 문서 원문 확인).
보관 정책 — 리포트는 자산이고 아티팩트는 재고다
| 대상 | 보관 | 이유 |
|---|---|---|
| 요약 결과(통과·실패·소요·플래키 수) | 길게 (분기~년) | 추세 지표의 원천 |
| HTML·JUnit 리포트 | 중간 (수주) | 릴리스 회고 때 다시 본다 |
| 트레이스·비디오·HAR | 짧게 (7~14일) | 용량 대비 효용이 급락한다 |
| 개인정보·토큰이 담길 수 있는 아티팩트 | 최소 + 접근 제한 | 유출 표면이다 |
핵심은 요약과 원본을 분리해 수명을 다르게 주는 것 이다. 둘을 한 덩어리로 묶으면 "용량이 터져서 다 지웠더니 추세도 사라졌다" 가 된다. 그리고 아티팩트 저장소는 테스트 데이터를 담는다는 사실을 잊지 않는다 — 실제 고객 데이터로 테스트하는 환경이라면 아티팩트 보관은 개인정보 보관이다.
상관 ID — 테스트 실행과 서버 로그를 잇는다
"UI 는 500 을 보여줬다" 에서 멈추면 QA 는 결함을 넘길 수 없다. 그 요청이 서버에서 무엇을 했는지 를 같은 리포트에서 찾아갈 수 있어야 한다. 방법은 하나다 — 테스트가 만든 식별자를 모든 요청에 붙이고, 그 값으로 서버 로그를 검색한다.
// src/core/ids.ts — 코어. 제품을 모른다
export function newCorrelationId(seed: string): string {
const slug = seed.replace(/[^a-zA-Z0-9]+/g, '-').slice(0, 24).toLowerCase();
return `qa-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${slug}`;
}// src/domain/fixtures/diagnostics.ts — 실패했을 때만 증거를 붙인다
import { test as base, expect } from '@playwright/test';
export const test = base.extend<{ diagnostics: void }>({
diagnostics: [
async ({ page, correlationId }, use, testInfo) => {
const console_: string[] = [];
page.on('console', (m) => console_.push(`[${m.type()}] ${m.text()}`));
page.on('pageerror', (e) => console_.push(`[pageerror] ${e.message}`));
await use();
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('correlation-id', { body: correlationId, contentType: 'text/plain' });
await testInfo.attach('console.log', {
body: console_.join('\n'),
contentType: 'text/plain',
});
await testInfo.attach('server-logs', {
body: `${process.env.LOG_SEARCH_URL}?q=${encodeURIComponent(correlationId)}`,
contentType: 'text/plain',
});
}
},
{ auto: true }, // 모든 테스트에 자동 적용. 테스트는 이 픽스처를 선언하지 않는다
],
});설계 포인트가 셋이다. auto: true 로 전역 적용한다 — 진단은 테스트가 선택하는
기능이 아니다. 실패했을 때만 첨부한다 — 통과한 수천 건에 로그를 붙이면 리포트가
쓸모 없이 커진다. 로그 검색 URL 을 첨부한다 — 로그 원문을 끌어오지 않고 링크를
남기면 권한 경계를 넘지 않으면서도 클릭 한 번으로 이어진다.
서버가 x-correlation-id 를 받아 로그에 그대로 찍어 주는 것 이 전제다. 그게 안 되면
이 구조는 반쪽이 된다. 그걸 개발팀에 요구하는 방법이 레슨 4의 주제다.
읽히는 실패 메시지 — 단언과 단계를 설계한다
리포트 품질의 절반은 실패 한 줄의 문장 에서 결정된다.
// ✗ 무엇이 왜 틀렸는지 알 수 없다
await expect(page.locator('.badge')).toHaveText('취소완료');
// Error: expected '취소완료', received '배송중' ← 어떤 주문의 배지인가?
// ○ 대상·기대·맥락이 메시지에 들어간다
await test.step(`주문 ${orderNo} 취소 후 상태 확인`, async () => {
await expect(
page.getByTestId(`order-${orderNo}-status`),
`주문 ${orderNo}(결제완료 상태로 시딩)은 취소 후 "취소완료" 가 돼야 한다`,
).toHaveText('취소완료');
});test.step() 이 하는 일은 실패 지점을 좁히는 것 이다. 20줄짜리 테스트가 그냥
"실패" 로 뜨는 것과 "3단계 — 주문 취소 요청" 에서 실패로 뜨는 것은 진단 시간이 다르다.
단계 이름은 사용자 행동의 언어 로 쓴다. 그리고 한 번에 여러 사실을 모아야 할 때는
expect.soft 를 쓴다 — 첫 단언에서 멈추지 않고 끝까지 검사해, "제목도 틀리고 금액도
틀렸다" 를 한 번에 보여준다. 다만 소프트 단언을 남용하면 실패 원인이 여러 개로 흩어져
오히려 읽기 어려워지므로, 같은 화면의 독립적인 필드 검증에만 쓴다.
플래키와 진짜 실패를 리포트에서 가른다
재시도를 켜 둔 스위트의 리포트는 세 종류의 결과를 담는다. 이것을 구분하지 않으면 "어제는 3개 실패, 오늘은 1개 실패" 라는 숫자가 아무 의미도 갖지 않는다.
| 결과 | 뜻 | 대응 |
|---|---|---|
| 실패(unexpected) | 재시도까지 전부 실패 | 즉시 트리아지 |
| 플래키(flaky) | 처음 실패, 재시도에서 통과 | 별도 집계. 임계값 넘으면 격리 |
| 통과(expected) | 첫 시도에 통과 | — |
Playwright 는 이 셋을 결과 상태로 구분해 보고하므로, 커스텀 리포터에서 바로 갈라 적을 수 있다.
// src/core/reporters/triage-reporter.ts — 플래키를 따로 센다
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
export default class TriageReporter implements Reporter {
private flaky: string[] = [];
private failed: string[] = [];
onTestEnd(test: TestCase, result: TestResult) {
if (test.outcome() === 'flaky') {
// 재시도별 오류 메시지가 다르면 환경·타이밍 문제일 확률이 높다
const distinct = new Set(test.results.map((r) => r.error?.message?.slice(0, 120) ?? ''));
this.flaky.push(`${test.title} (서로 다른 오류 ${distinct.size}종)`);
} else if (result.status === 'failed' || result.status === 'timedOut') {
this.failed.push(`${test.title} — ${result.status}`);
}
}
onEnd() {
console.log(`실패 ${this.failed.length}건 / 플래키 ${this.flaky.length}건`);
this.flaky.forEach((l) => console.log(` [flaky] ${l}`));
}
}distinct.size 를 세는 이유가 핵심이다. 같은 테스트가 재시도마다 다른 메시지로
실패하면 타이밍·환경 쪽, 같은 메시지로 실패하면 제품·테스트 로직 쪽 일 확률이 높다.
이 한 줄이 트리아지의 첫 분기를 자동화한다. 플래키를 실패와 같은 칸에 넣지 않는
것 도, 통과로 숨기지 않는 것 도 둘 다 중요하다 — 전자는 트리아지를 마비시키고
후자는 품질 저하를 안 보이게 만든다.
리포트의 층 — 누가 보는가에 따라 다르게 쓴다
한 리포트로 세 독자를 다 만족시키려 하면 아무도 안 읽는다. 같은 데이터, 세 층.
| 독자 | 보고 싶은 것 | 전달 형태 |
|---|---|---|
| 릴리스 담당 | 배포해도 되는가 | 한 줄 판정 + 게이트 위반 항목 |
| 개발자 | 내 변경이 뭘 깼나 | 실패 목록 + 트레이스·로그 직링크 |
| QA·자동화 담당 | 스위트가 건강한가 | 플래키율·소요 시간·실패 반복 패턴 |
실무에서는 1층을 PR 코멘트나 채팅 알림으로, 2층을 HTML 리포트로, 3층을 주간 지표로
낸다. 여러 샤드로 나눠 돌렸다면 각 샤드가 blob 리포트를 남기고 끝에서 하나로
합쳐 2층을 만든다 — 샤드별로 리포트가 흩어지면 개발자가 자기 실패를 찾지 못한다.
1층에서 반드시 피해야 할 것은 "테스트 42개 실패" 같은 숫자만 주는 알림 이다. 그 알림을 받은 사람이 할 수 있는 행동이 없다. 최소한 실패를 묶은 범주 하나와 링크 하나 가 있어야 행동이 시작된다 — "로그인 셋업 실패로 38건이 의존성 실패(링크)" 처럼.
실무에서 왜 필요한가
첫째, 진단 시간이 자동화의 실질 비용 이다. 작성 비용은 한 번이지만 진단 비용은 실패할 때마다 든다. 하루 한 번 돌고 주 3회 실패하는 스위트에서 진단이 30분이면 월 6시간이 그냥 사라진다 — 레슨 5의 ROI 계산에 이 항목이 들어간다.
둘째, 리포트가 책임 공방을 숫자로 바꾼다. "자동화가 또 깨졌다" 와 "이 실패는
상관 ID qa-m3x... 의 요청에서 서버가 500 을 반환했고 서버 로그가 여기 있다" 는 다른
대화다. 전자는 QA 가 변명하는 자리, 후자는 개발자가 로그를 여는 자리가 된다.
셋째, 플래키를 숫자로 들고 있어야 격리를 협상할 수 있다. "이 테스트는 좀 불안해요" 로는 아무것도 바꿀 수 없다. "최근 30회 중 7회 플래키, 재시도마다 메시지가 달라 환경 쪽으로 의심" 이면 격리든 수정이든 결정이 난다(모듈 59 레슨 4가 격리 운영을 다룬다).