14.1 테스트 전략 — 어떤 위험에 어떤 증거를 살 것인가
테스트 스위트는 통과 여부를 알려주는 신호등이 아니라 위험별로 구매한 증거의 포트폴리오다. 이 문서는 테스트 수준을 비용×신뢰도 트레이드오프로 재구성하고, 테스트 대역이 늘수록 잃는 보장, 테스트 없는 코드에 증거를 넣는 특성화 테스트와 seam, 예제 대신 불변식을 검증하는 속성 기반 테스트, flaky의 구조적 원인, 그리고 커버리지가 보장하지 않는 것을 뮤테이션으로 관찰하는 방법을 다룬다.
학습 목표
- 테스트 수준(단위·통합·E2E)별 작성·실행·유지 비용과 잡아내는 결함 종류를 근거로 테스트 배치를 판단한다.
- 테스트 대역이 늘어날 때 잃는 보장을 식별하고 대역 사용 여부를 판단한다.
- 특성화 테스트와 seam으로 테스트 없는 코드에 안전한 변경 경로를 구현한다.
- 예제 기반 테스트가 놓치는 결함을 속성 기반 테스트의 불변식으로 표현한다.
- flaky 테스트의 구조적 원인을 분석하고, 커버리지와 뮤테이션 결과로 테스트 스위트의 검증 강도를 평가한다.
배경: "테스트를 얼마나 써야 충분한가"라는 질문의 결함
주문 확정 서비스를 물려받은 팀을 생각하자. 테스트가 없고, 시간대별 할증 로직과 전역 재고 상태와 결제 API 호출이 한 함수에 얽혀 있다. 여기에 "중복 주문 방지"라는 변경을 안전하게 가해야 한다. 이때 "커버리지 80%를 달성하자"나 "단위:통합:E2E를 70:20:10으로 맞추자" 같은 답은 질문의 형태부터 잘못됐다. 테스트의 양은 목적이 아니라 결과이기 때문이다.
테스트 하나는 하나의 실험이다. 실패하면 "이 조건에서 이 가정이 깨진다"는 정보를 주고, 통과하면 "이 조건에서는 이 가정이 유지된다"는 증거를 준다. 그 이상도 이하도 아니다. 따라서 올바른 질문은 "얼마나 많이"가 아니라 인트로의 판단 루프를 따른다. 어떤 불변식이 깨지면 가장 비싼가, 그 위험을 줄이는 가장 싼 실험은 무엇인가, 그 실험이 보장하지 못하고 남기는 위험은 무엇인가.
이 관점에서 보면 통념적 처방들의 근거와 한계도 분명해진다. 테스트 피라미드(단위가 많고 E2E가 적은 배치)는 "같은 증거라면 싸고 빠른 수단으로 사라"는 원칙의 표현으로는 유효하지만, 70:20:10 같은 비율 자체는 경험칙일 뿐 어떤 시스템에나 맞는 실증된 상수가 아니다. 로직이 얇고 통합이 대부분인 게이트웨이 서비스라면 통합 테스트 중심의 배치가 원칙에 더 충실할 수 있다.
핵심 개념
테스트 수준: 비용과 신뢰도의 트레이드오프
테스트 수준을 나누는 본질은 디렉터리 구조가 아니라 실험의 통제 범위다. 통제 범위가 좁을수록 실험은 싸고 빠르고 진단이 정확하지만, 통제한 부분이 실제와 다를 위험을 남긴다.
| 수준 | 통제 범위 | 잡는 결함 | 작성 비용 | 실행 비용 | 유지 비용 |
|---|---|---|---|---|---|
| 단위 | 의존성을 대역으로 통제 | 로직 오류, 경계값, 상태 전이 | 낮음 | ms 단위, 병렬 가능 | 구조 변경에 민감 |
| 통합 | 일부 실제 의존성 포함 | 계약 불일치, 쿼리·직렬화 오류, 설정 오류 | 중간 | 초 단위, 환경 필요 | 환경 관리 부담 |
| E2E | 통제 최소, 전체 시스템 | 배선 오류, 여정 단절, 환경 조합 문제 | 높음 | 분 단위, 직렬화 경향 | 가장 취약(flaky) |
세 층위로 나눠 읽으면 이렇다.
- 동작 모델: 단위 테스트가 빠른 이유는 프로세스 안에서 함수 호출만 하기 때문이고, E2E가 느리고 불안정한 이유는 네트워크·브라우저·데이터베이스라는 통제 불가능한 요소의 수가 실험에 그대로 들어오기 때문이다. 실패 확률이 독립적인 구성 요소 n개를 지나는 테스트의 성공률은 각 성공률의 곱으로 떨어진다. 99% 안정적인 단계 10개를 지나는 E2E는 이론상 최대 90.4%의 안정성을 갖는다.
- 설계 배경: 수준을 나누는 이유는 진단 비용 때문이다. E2E 실패는 "어딘가 깨졌다"를 알려주지만 어디인지는 알려주지 않는다. 단위 테스트 실패는 파일과 함수를 가리킨다. 결함 검출과 결함 위치 추적은 다른 능력이고, 아래 수준일수록 후자가 강하다.
- 경계 조건: 단위 테스트만으로 충분해지지 않는 지점은 구성 요소 사이의 계약이다. 각 모듈이 자기 가정 안에서 완벽해도 가정끼리 어긋나면 시스템은 깨진다. 챕터 0의 장애가 정확히 이 형태였다. 애플리케이션도 SDK도 각자의 명세대로 동작했지만, "총 시도는 1회"라는 암묵적 가정과 "기본 재시도 2회"라는 새 기본값이 통합 지점에서 충돌했다.
테스트 대역: 통제의 대가로 잃는 보장
단위 테스트의 속도와 결정성은 의존성을 테스트 대역(test double)으로 바꿔 얻는다. 대역의 종류마다 사는 것과 잃는 것이 다르다.
- 스텁(stub): 정해진 응답을 돌려준다. 상호작용의 입력 조건을 통제한다.
- 목(mock): 기대한 호출이 일어났는지 검증한다. 상호작용 자체를 단언 대상으로 만든다.
- 페이크(fake): 실제 동작하는 단순 구현(인메모리 저장소 등)이다. 계약의 의미를 부분적으로 유지한다.
대역을 쓰는 순간 테스트는 실제 의존성이 아니라 의존성에 대한 우리의 가정을 검증한다. 스텁이 { status: "ok" }를 돌려주도록 썼다면, 실제 결제 API가 그 형태로 응답하는지는 이 테스트의 증거 범위 밖이다. 실제 API의 응답 형식이 바뀌어도 스텁은 옛 가정을 계속 돌려주므로 테스트는 통과하고 프로덕션은 깨진다. 이것이 계약 표류(contract drift)다. 대역이 많은 스위트일수록 "우리 가정 안에서의 정합성"이라는 증거는 쌓이지만 "가정이 현실과 맞는다"는 증거는 늘지 않으므로, 가정 자체를 검증하는 통합 테스트나 계약 테스트가 별도로 필요하다.
목은 추가 비용이 있다. 호출 순서와 인자를 단언하는 테스트는 구현의 내부 구조에 결합된다. 동작이 같아도 호출 구조를 바꾸는 리팩터링마다 테스트가 깨진다면, 그 테스트는 회귀를 잡는 증거가 아니라 현재 구현의 복제본이다. 판단 기준은 그 상호작용이 외부에서 관찰 가능한 계약인가다. "결제 API를 정확히 한 번 호출한다"는 중복 결제를 막는 계약이므로 목으로 단언할 가치가 있다. "내부 헬퍼를 어떤 순서로 호출한다"는 구현 세부이므로 단언하면 유지 비용만 산다.
레거시 코드: 특성화 테스트와 seam
Michael Feathers는 레거시 코드를 "테스트 없는 코드"로 정의했다. 테스트가 없으면 변경의 안전을 확인할 수단이 없고, 테스트를 넣으려면 구조를 바꿔야 하는데, 구조를 바꾸는 것 자체가 검증 없는 변경이다. 이 순환을 끊는 도구가 특성화 테스트와 seam이다.
특성화 테스트(characterization test) 는 "코드가 해야 하는 일"이 아니라 "코드가 지금 하는 일" 을 기록한다. 명세는 소실됐어도 현재 동작은 관찰할 수 있고, 현재 동작이야말로 사용자와 다른 시스템이 실제로 의존하는 계약이다. 절차는 실험 그 자체다. 입력을 넣고, 실패하는 단언(예: assert.equal(result, null))을 쓰고, 실행해서 실제 값을 확인하고, 그 값을 단언에 기록한다. 이 테스트는 정확성의 증거가 아니라 변경 전후의 동작 동일성에 대한 증거다. 이상해 보이는 동작(음수 수량이 통과된다든가)도 일단 기록한다. 그것이 버그인지 다른 시스템이 의존하는 동작인지는 별도의 조사가 필요한 문제이고, 기록 없이 "고치면" 무엇이 바뀌었는지조차 알 수 없게 된다.
seam은 코드를 수정하지 않고 그 지점의 동작을 바꿀 수 있는 자리다. 얽힌 코드는 seam이 없어서 테스트할 수 없다. 다음은 이 챕터 실습(exercises/ch-14/)의 레거시 주문 확정 서비스에서 가져온 구조다.
// 구조를 보이기 위한 발췌로, 전체 코드는 exercises/ch-14/에 있다
const inventory = new Map<string, number>(); // 모듈 전역 상태
export async function confirmOrder(items: OrderItem[]) {
const hour = new Date().getHours(); // 시스템 시계에 직접 의존
const nightSurcharge = hour >= 22 || hour < 6 ? 3_000 : 0;
// ...재고 차감(전역 상태 변경)...
const res = await fetch(`${PAYMENT_URL}/charge`, { // 외부 시스템 직접 호출
method: "POST",
body: JSON.stringify({ amount: total + nightSurcharge }),
});
// ...
}이 함수의 테스트는 실행 시각에 따라 결과가 다르고, 결제 서버가 없으면 실패하며, 테스트끼리 재고 상태를 공유한다. 가장 작은 seam은 의존성을 매개변수로 끌어올리는 것이다.
// 기본값이 기존 동작을 보존하므로 호출부는 수정하지 않아도 된다
export async function confirmOrder(
items: OrderItem[],
deps = { now: () => new Date(), charge: chargePayment, inventory },
) {
const hour = deps.now().getHours();
// ...
}이제 테스트는 deps로 시계를 고정하고 결제를 페이크로 바꾸고 재고를 격리할 수 있다. 순서가 중요하다. 특성화 테스트로 현재 동작을 먼저 고정하고, seam 도입이 그 테스트를 깨지 않는지 확인한 뒤에, 비로소 원하는 변경을 가한다. seam 도입 자체는 동작을 보존하는 최소 변경이어야 하며, 이 단계에서 리팩터링 욕심을 섞으면 안전망 없는 변경으로 돌아간다.
속성 기반 테스트: 예제 대신 불변식을 검증한다
예제 기반 테스트는 "입력 X에 출력 Y"를 점으로 검증한다. 점을 아무리 찍어도 작성자가 상상한 입력만 검증된다는 한계가 있다. 속성 기반 테스트(property-based testing)는 QuickCheck(Claessen & Hughes, ICFP 2000)이 정립한 접근으로, 모든 유효 입력에 대해 성립해야 할 속성을 쓰고 도구가 입력을 무작위 생성해 반례를 찾는다.
주문 확정의 불변식을 fast-check(fast-check 4, Node 24 기준)로 표현하면 다음과 같다.
// order-total.property.test.ts — node --test로 실행한다
import { test } from "node:test";
import fc from "fast-check";
import { calcTotal } from "./order-service.ts";
const itemArb = fc.record({
unitPrice: fc.integer({ min: 0, max: 10_000_000 }),
quantity: fc.integer({ min: 1, max: 100 }),
});
test("총액은 품목 부분합의 합이며 음수가 될 수 없다", () => {
fc.assert(
fc.property(fc.array(itemArb, { maxLength: 30 }), (items) => {
const total = calcTotal(items);
const expected = items.reduce((s, i) => s + i.unitPrice * i.quantity, 0);
return total === expected && total >= 0;
}),
);
});예제 테스트가 놓치기 쉬운 것을 이 방식이 잡는 이유는 생성기가 작성자의 상상 밖 조합(빈 배열, 최대값 곱셈의 오버플로, 할인이 총액을 넘는 경우)을 기계적으로 탐색하기 때문이다. 멱등성 같은 관계 속성도 예제보다 자연스럽게 표현된다. "같은 멱등성 키로 두 번 확정하면 주문은 하나만 생기고 두 응답은 같다"는 confirm(k, x) 후 confirm(k, x)의 결과 비교로 쓸 수 있다.
반례를 찾으면 도구는 축소(shrinking) 를 수행한다. 실패를 일으킨 30개 품목의 무작위 배열을 그대로 보고하는 대신, 실패가 유지되는 한도까지 입력을 줄여 최소 반례(예: [{ unitPrice: 0, quantity: 1 }] 하나)를 제시한다. 무작위 테스트의 진단 비용을 예제 테스트 수준으로 낮추는 장치이며, 원 QuickCheck 논문의 무작위 생성 아이디어에 이후 구현들이 표준으로 정착시킨 기능이다.
경계도 분명하다. 속성 기반 테스트는 속성으로 표현된 것만 검증한다. "총액이 부분합과 같다"는 속성은 할증 정책이 기획 의도와 맞는지 말해주지 않는다. 좋은 속성을 찾는 일(불변식, 왕복 변환 decode(encode(x)) === x, 참조 구현과의 비교, 멱등성)이 도구 사용법보다 어렵고, 그 불변식 목록은 챕터 13의 명세 활동에서 나온다.
flaky 테스트: 불안정은 우연이 아니라 구조다
코드 변경 없이 통과와 실패를 오가는 테스트를 flaky라 한다. Luo 등이 오픈소스 프로젝트의 flaky 수정 이력 201건을 분석한 연구(FSE 2014)에서 원인의 대부분은 세 범주에 집중됐다. 비동기 대기(async wait, 약 45%), 동시성(약 20%), 테스트 순서 의존(약 12%)이다. 셋 다 구조적 원인이고, 따라서 구조로 고칠 수 있다.
| 구조적 원인 | 전형적 형태 | 구조적 해결 |
|---|---|---|
| 시간 의존 | sleep(500) 후 결과 확인, 시각·시간대에 따른 분기 | 완료 신호를 기다리는 명시적 동기화, seam으로 주입한 가짜 시계 |
| 공유 상태 | 전역 변수·같은 DB 스키마·파일 시스템을 테스트가 공유 | 테스트별 격리된 픽스처, 실행 순서 무작위화로 조기 검출 |
| 비동기 순서 | 이벤트·메시지 도착 순서 가정, 경쟁 조건 | 순서 비의존 단언, 결정적 스케줄링, 완료 조건 폴링 |
sleep(500)이 구조적 결함인 이유를 보자. 이 테스트는 "작업이 500ms 안에 끝난다"는 성능 가정과 "작업 결과가 올바르다"는 기능 가정을 하나의 실험에 섞는다. CI 머신이 느린 날 성능 가정이 깨지면 기능이 멀쩡해도 실패한다. 대기 시간을 늘리면 실패 빈도는 줄지만 스위트는 느려지고 가정은 여전히 섞여 있다. 올바른 수리는 완료 이벤트나 상태 폴링으로 "끝났다"는 신호를 기다린 뒤 결과만 단언하는 것이다.
flaky의 비용은 개별 테스트의 소음을 넘는다. 실패가 "코드가 깨졌다"와 "또 그 테스트네" 중 무엇인지 구분할 수 없게 되는 순간, 스위트 전체의 증거 가치가 떨어진다. 재실행해서 통과하면 넘어가는 관행은 이 저하를 가속한다. 재실행 통과는 결함의 부재가 아니라 비결정성의 존재를 확인해준 것이고, 그 비결정성이 테스트에만 있는지 제품 코드의 경쟁 조건인지는 조사해야 알 수 있다. Luo 등의 분석에서도 수정의 상당수는 테스트가 아니라 제품 코드 쪽 결함이었다. 신뢰할 수 있는 테스트의 조건은 결국 하나로 요약된다. 같은 코드에 대해 같은 판정을 내리는 결정성이며, 이는 시간·상태·순서라는 세 가지 비결정 원천을 테스트의 통제 아래 두어야 얻어진다.
커버리지가 보장하는 것, 뮤테이션이 확인하는 것
커버리지는 테스트 실행 중 각 코드 단위(문장·분기)가 실행됐는지의 기록이다. 보장하는 것은 정확히 그것뿐이다. 실행됐다는 것이지 검증됐다는 것이 아니다. 실행 후 결과를 단언하지 않으면 커버리지는 오르지만 증거는 없다. 다음을 직접 관찰할 수 있다(Node 24 기준).
// coverage-illusion.test.ts — 단언 없는 테스트도 커버리지를 만든다
import { test } from "node:test";
import { calcTotal } from "./order-service.ts";
test("단언이 없어도 커버리지는 100%다", () => {
calcTotal([{ unitPrice: 1000, quantity: 2 }]); // 결과를 버린다
});node --test --experimental-test-coverage
# calcTotal의 라인 커버리지 100%가 보고되지만
# calcTotal이 어떤 값을 돌려줘도 이 스위트는 통과한다그래서 커버리지의 올바른 용도는 방향이 반대다. 높은 수치는 검증 강도를 증명하지 못하지만, 낮은 수치는 검증의 공백을 확실히 알려준다. 실행조차 되지 않은 코드는 어떤 단언의 보호도 받지 않는다. 커버리지는 공백 탐지기로 쓰고, 검증 강도는 다른 도구로 확인한다.
그 도구가 뮤테이션 테스트(mutation testing)다. 제품 코드에 작은 결함(뮤턴트)을 기계적으로 심고 — <=를 <로, +를 -로 — 테스트 스위트가 그 결함을 실패로 잡는지(뮤턴트를 죽이는지) 측정한다. 원리는 수동으로도 관찰할 수 있다. 위의 단언 없는 테스트가 있는 상태에서 calcTotal의 +를 -로 바꿔 실행해 보면 스위트는 여전히 통과한다. 커버리지 100%인 코드에 심은 결함이 살아남는 것을 눈으로 보는 것이 "커버리지 ≠ 검증 강도"의 가장 직접적인 증거다. Stryker 같은 도구는 이 과정을 자동화해 뮤테이션 점수(죽인 뮤턴트/전체 뮤턴트)를 보고한다.
뮤테이션 점수도 만능 지표가 아니다. 전체 뮤테이션 실행은 스위트를 뮤턴트 수만큼 반복 실행하므로 비용이 크고(도구는 커버리지 정보로 실행 범위를 줄인다), 동작이 원본과 동일한 동등 뮤턴트(equivalent mutant)는 원리적으로 죽일 수 없어 분모를 오염시킨다. 그래서 뮤테이션은 상시 게이트보다 핵심 로직의 검증 강도를 주기적으로 감사하는 용도에 맞는다.
실무 관점
역피라미드가 항상 잘못은 아니지만, 이유 없는 역피라미드는 잘못이다
E2E 중심 스위트가 느려지고 불안정해지는 팀의 전형적 경로는 이렇다. 초기에 E2E가 가장 "실제 같아서" 신뢰를 얻는다 → 시나리오가 늘며 실행 시간이 수십 분이 된다 → flaky 재실행이 관행이 된다 → 실패 신호가 무시된다. 이때 필요한 판단은 각 E2E가 사는 증거를 아래 수준에서 더 싸게 살 수 있는지 검토하는 것이다. 배선과 핵심 여정 몇 개만 E2E에 남기고, 로직 검증은 단위로, 계약 검증은 통합·계약 테스트로 내린다. 반대로 로직이 거의 없는 얇은 서비스에서 목으로 가득한 단위 테스트를 늘리는 것도 같은 오류의 대칭형이다. 이때 각 단위 테스트가 사는 증거는 "목이 우리 가정대로 응답하면 우리가 가정대로 동작한다"는 동어반복에 가깝다.
테스트가 리팩터링을 방해한다면 대역 경계를 의심한다
"리팩터링할 때마다 테스트 수십 개를 고친다"는 불평은 대개 테스트 양의 문제가 아니라 단언 대상의 문제다. 내부 호출 구조를 목으로 단언한 테스트는 구현에 결합되어, 동작 보존 변경에도 깨진다. 이런 스위트는 회귀 검출(동작이 바뀌면 실패)과 오탐(동작이 같아도 실패)을 구분하지 못하게 만들어 리팩터링의 안전망이라는 테스트 본연의 가치를 잃는다. 공개 계약 수준에서 입력과 관찰 가능한 결과를 단언하도록 경계를 옮기면, 테스트 수를 줄이면서 증거는 유지할 수 있다.
flaky 격리 정책에는 만기가 필요하다
flaky를 즉시 고칠 수 없을 때 격리(quarantine) — 실패해도 파이프라인을 막지 않게 분리 — 는 스위트의 신호를 지키는 합리적 완충이다. 그러나 만기 없는 격리는 삭제의 완곡어법이다. 격리 목록에 들어간 테스트가 사던 증거는 그 순간부터 없는 것이므로, 격리는 "N일 안에 수리 또는 삭제 판단"이라는 기한과 함께 운영해야 한다. 수리 우선순위는 그 테스트가 덮던 불변식의 가치로 정한다.
테스트 코드의 품질 기준은 제품 코드와 다르지만 낮지 않다
테스트 코드에서 우선하는 성질은 추상화가 아니라 읽는 즉시 실험 조건이 보이는 명료함이다. 픽스처를 과도하게 공유하고 헬퍼를 여러 겹 쌓으면 제품 코드처럼 DRY해 보이지만, 실패했을 때 "이 테스트는 정확히 무엇을 가정했나"를 복원하는 비용이 커지고 공유 상태발 flaky의 원인이 된다. 약간의 중복을 허용하더라도 각 테스트가 자기 조건을 스스로 서술하게 유지하는 편이 증거로서의 가치가 높다.
더 깊이: 스위트 전체를 하나의 판정기로 본다
개별 테스트가 아니라 스위트 전체를 "변경을 넣으면 합격/불합격을 내는 판정기"로 보면, 판정기의 품질은 두 오류율로 요약된다. 결함 있는 변경을 통과시키는 거짓 합격(뮤테이션 점수가 근사하는 값)과, 정상 변경을 실패시키는 거짓 불합격(flaky와 구현 결합 테스트가 만드는 값)이다. 두 오류율은 트레이드오프 관계가 아니라는 점이 중요하다. 단언 없는 테스트는 둘 다 개선하지 못하면서 실행 시간만 쓰고, 구현에 결합된 목 테스트는 거짓 불합격을 늘리면서 거짓 합격은 줄이지 못할 수 있다. 반대로 불변식을 계약 수준에서 단언하는 테스트는 두 오류율을 동시에 낮춘다. "테스트를 늘리자"는 계획을 이 두 지표에 대한 예상 효과로 번역해 보면, 어떤 테스트 추가가 투자이고 어떤 것이 비용인지 구분된다.
판정기의 속도도 증거의 가치에 포함된다. 10분 걸리는 스위트는 개발자가 변경마다 돌리지 않게 되고, 몰아서 검증된 큰 변경은 실패 시 원인 후보가 많아 진단이 비싸진다. 같은 증거를 산다면 빠른 판정기가 더 자주, 더 작은 변경 단위에 적용되므로 총 가치가 크다.
정리
- 테스트는 실험이고 스위트는 위험별로 구매한 증거의 포트폴리오다. "얼마나 많이"가 아니라 "어떤 불변식의 위험을 어떤 비용으로 줄이는가"로 판단한다.
- 테스트 수준은 통제 범위의 선택이다. 통제가 좁을수록 싸고 진단이 정확하지만 통제한 부분이 현실과 다를 위험(계약 불일치)을 남기며, 그 위험은 통합 수준에서만 검증된다.
- 테스트 대역은 속도·결정성과 맞바꿔 "의존성에 대한 가정"만 검증하게 만든다. 관찰 가능한 계약만 목으로 단언하고, 가정 자체의 검증을 별도로 배치한다.
- 테스트 없는 코드에는 특성화 테스트로 현재 동작을 먼저 고정하고, 동작을 보존하는 최소 seam을 도입한 뒤 변경한다.
- flaky는 우연이 아니라 시간·공유 상태·비동기 순서라는 구조적 원인의 증상이며, 재실행은 증거를 복구하지 않는다. 커버리지는 검증의 공백을 찾는 도구이고 검증 강도는 뮤테이션으로 확인한다.
확인 문제
- 결제 모듈의 단위 테스트 200개가 모두 통과하는데, 결제 API 제공사가 응답 필드
status의 값 체계를 바꾸자 프로덕션 결제가 전부 실패했다. 테스트 스위트의 어떤 구조가 이 결함을 원리적으로 잡을 수 없게 했는지 설명하고, 어떤 종류의 테스트를 어디에 추가해야 하는지 판단하라. - CI에서 주문 테스트가 약 5% 확률로 실패한다. 동료가 "3회 재시도 후 통과하면 성공 처리"하는 설정을 제안한다. 이 제안이 감추는 두 가지 위험을 구분하고, 재시도 대신 밟아야 할 진단 절차를 제시하라.
- 팀의 커버리지는 92%인데 배포마다 회귀가 나온다. 뮤테이션 테스트를 돌리자 점수가 41%였다. 두 수치의 조합이 스위트에 대해 말해주는 것은 무엇이며, 커버리지를 더 올리는 것과 기존 테스트의 단언을 강화하는 것 중 무엇을 우선해야 하는가?
- "할인 적용 후 총액은 0 이상이고 할인 전 총액을 넘지 않는다"를 속성 기반 테스트로 작성해 통과했다. 이 테스트가 여전히 보장하지 못하는 결함의 예를 두 가지 들고, 각각을 덮을 증거 수단을 제시하라.
정답과 해설
- 200개 테스트가 모두 결제 API를 스텁·목으로 대체했다면, 스위트 전체가 "API가 우리 가정대로 응답한다"는 전제 위에 서 있다. 외부의 계약 변경은 그 전제 밖의 사건이므로 어떤 단위 테스트도 원리적으로 잡을 수 없다(계약 표류). 필요한 것은 가정 자체를 검증하는 증거다. 실제 API(또는 제공사의 샌드박스)에 대해 요청·응답 형식을 검증하는 계약·통합 테스트를 배치 경계에 추가하고, 단위 테스트의 스텁이 그 계약 정의를 공유하게 만들어 가정과 검증이 한 곳에서 갱신되게 한다. 제공사가 사전 공지 없이 바꿀 수 있는 환경이라면 프로덕션 SLI(결제 성공률)의 이상 감지가 마지막 방어선이 된다.
- 첫째, 테스트 자체의 결함(시간·상태·순서 의존)을 영구히 가려 스위트의 판정 신뢰도를 낮춘다. 둘째, 더 위험하게는 제품 코드의 경쟁 조건이 5% 확률로 드러나고 있는 것일 수 있는데, 재시도는 이 신호를 소음으로 분류해버린다. 진단 절차: 해당 테스트를 반복 실행해 재현율을 확인하고, 실패 시점의 로그·상태로 세 가지 구조적 원인(시간 대기, 공유 상태, 비동기 순서) 중 무엇인지 좁힌다. 테스트 순서를 무작위화하거나 단독 실행과 전체 실행의 결과를 비교하면 순서 의존을 분리할 수 있다. 원인이 제품 코드면 결함 수리가 우선이고, 테스트 구조면 명시적 동기화·격리로 수리하되, 즉시 수리가 불가능하면 기한 있는 격리로 옮긴다.
- 커버리지 92%는 코드 대부분이 실행된다는 뜻이고, 뮤테이션 41%는 실행되는 코드에 심은 결함의 절반 이상을 스위트가 알아채지 못한다는 뜻이다. 조합하면 "실행은 하지만 결과를 충분히 단언하지 않는" 테스트가 많다는 진단이 나온다. 이 상태에서 커버리지를 더 올리는 것은 이미 약한 것으로 판명된 종류의 증거를 늘리는 일이므로, 우선순위는 단언 강화다. 뮤테이션 리포트에서 살아남은 뮤턴트의 위치가 정확히 "실행되지만 검증되지 않는 지점"의 목록이므로, 회귀가 잦은 핫스팟과 겹치는 곳부터 단언을 보강하는 것이 가장 싼 개선 경로다.
- 이 속성은 총액의 범위 불변식만 검증한다. 보장하지 못하는 예: (1) 할인율 계산 자체가 기획과 다른 경우 — 20% 할인 쿠폰이 10%만 적용돼도 "0 이상, 원금 이하"는 성립한다. 기대값을 아는 대표 예제 기반 테스트나 기획 수치를 담은 테이블 기반 테스트가 필요하다. (2) 총액 이외의 부수 효과 결함 — 할인 이력이 기록되지 않거나 재고가 잘못 차감되어도 이 속성은 통과한다. 상태 변화까지 관찰하는 테스트(또는 "확정 후 재고 = 확정 전 재고 − 주문 수량" 같은 별도 속성)로 덮는다. 속성 기반 테스트는 쓴 속성의 집합만큼만 검증한다는 경계를 보여주는 문제다.
참고 자료
- Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — 특성화 테스트, seam, 의존성 깨기 기법의 원전으로, 테스트 없는 코드에 안전망을 넣는 절차를 확인한다.
- Koen Claessen, John Hughes, QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs (ICFP 2000) — 속성 기반 테스트의 원 논문으로, 속성과 생성기라는 핵심 개념의 출처다.
- fast-check 공식 문서 — JavaScript/TypeScript 속성 기반 테스트의 구현 기준으로, 생성기(arbitrary)와 shrinking의 실제 동작을 확인한다.
- Qingzhou Luo, Farah Hariri, Lamyaa Eloussi, Darko Marinov, An Empirical Analysis of Flaky Tests (FSE 2014) — flaky 원인 분류(비동기 대기·동시성·순서 의존)와 수정 패턴의 실증 근거다.
- Stryker 공식 문서 — 뮤테이션 테스트의 도구 기준으로, 뮤턴트 종류와 점수 산정 방식을 확인한다.
- Node.js Test Runner 공식 문서 — 본문 예제가 사용한
node:test와 커버리지 옵션의 기준 문서다.