테스트 하네스 엔지니어링
테스트 코드를 넘어 환경 제어, 증거 수집, 재현성, agent repair boundary를 묶는 테스트 하네스 설계 원칙과 패키지 구조
핵심 요약
- 테스트 하네스는 스크립트가 아니라 환경·상태·도구·판정 경계를 묶어 신뢰 가능한 증거를 만드는 작업 장치입니다.
- entry contract·state controller·actuator wrapper·evidence collector·judge boundary·repair boundary 6계층으로 책임을 나눕니다.
- testops/ 폴더 묶음으로 시작하고 lifecycle 공유·다중 저장소·정책 안정성 조건 2개 이상이면 공용 패키지로 승격합니다.
- core는 타입·vocabulary·lifecycle만 두고 Playwright import나 DB reset 같은 구현은 절대 넣지 않습니다.
- agent repair는 allowedPaths/blockedPaths와 release·unknown 시 human approval 정책으로 경계를 강제합니다.
테스트 하네스 엔지니어링은 "테스트를 돌리는 스크립트"를 만드는 일이 아닙니다. 테스트가 신뢰할 증거를 만들도록 환경, 상태, 도구, 판정 경계를 설계하는 일입니다.
이 책에서 말하는 agentic test environment가 시스템 전체 관점이라면, 테스트 하네스는 그 안에서 실제 probe를 안전하게 실행하고 재현하게 만드는 작업 장치에 가깝습니다.
테스트 하네스는 무엇을 맡는가
| 계층 | 하네스가 맡는 일 | 없으면 생기는 문제 |
|---|---|---|
| entry contract | 어떤 seed, 계정, 환경 변수, feature flag로 시작할지 고정 | 같은 테스트가 매번 다른 전제에서 실행됨 |
| state controller | seed/reset, clock, queue, sandbox 상태를 준비 | flaky와 제품 회귀가 섞여 보임 |
| actuator wrapper | browser, API, workflow runner를 안전한 범위 안에서 노출 | probe마다 구현 방식과 권한이 제각각 |
| evidence collector | trace, screenshot, log, event, env fingerprint를 묶어 저장 | 실패해도 왜 실패했는지 설명 불가 |
| judge boundary | retry, quarantine, release gate, human approval를 연결 | green/red 의미가 사람마다 다름 |
| repair boundary | agent가 어디까지 재실행·수정 가능한지 제한 | agent가 사실상 무제한 self-approve 통로가 됨 |
로컬 폴더에서 시작하는 최소 하네스
가장 먼저 필요한 건 거대한 플랫폼이 아니라 아래 정도의 묶음입니다.
이 패키지는 "테스트를 예쁘게 정리"하려는 게 아닙니다. probe가 어떤 전제에서 시작했고 어떤 증거를 남겼는지 세션을 바꿔도 복원하려는 것입니다.
한 앱, 한 제품, 한 suite owner 수준에서는 이 구조로도 충분합니다.
중요한 건 폴더 이름이 아니라 entry contract, state, evidence, gate가 분리됐는가입니다.
언제 내부 패키지로 승격할까
아래 조건 중 2개 이상이 생기면 폴더가 아니라 공용 패키지로 올리는 편이 낫습니다.
- browser, API, workflow probe가 같은 lifecycle을 공유해야 한다.
- 여러 앱이나 저장소가 같은 seed/reset, evidence schema, gate vocabulary를 써야 한다.
- release gate와 agent repair loop가 같은 판정 규칙을 참조해야 한다.
- runner 구현보다 contract, evidence, policy의 안정성이 더 중요해졌다.
이 시점부터는 testops/ 아래 파일 묶음보다 packages/test-harness-*처럼
책임 단위가 분리된 구조가 유지보수에 유리합니다.
모노레포 패키지 구조 예시
아래는 agentic test environment 팀이 실제로 많이 쓰게 되는 분리 방식입니다.
이 구조의 포인트는 "패키지 수를 늘리자"가 아니라, 무엇이 재사용 자산이고 무엇이 probe별 구현인지 드러내자는 데 있습니다.
패키지별 책임 분리
| 패키지 | 맡는 책임 | 대표 export | 두면 안 되는 것 |
|---|---|---|---|
@org/test-harness-core | 하네스 공용 타입, lifecycle, gate vocabulary | EntryContract, ProbeResult, GateDecision | Playwright import, DB reset 구현 |
@org/test-harness-state | seed/reset, queue drain, clock freeze, env fingerprint | StateController, EnvFingerprint | 제품별 assertion helper |
@org/test-harness-browser | browser actuator wrapper, session bootstrapping, stable page object | BrowserRunner, createSession | gate 판정 로직 |
@org/test-harness-evidence | trace/log/screenshot/event를 evidence bundle로 정규화 | EvidenceBundle, EvidenceCollector | rerun policy, human approval 정책 |
@org/test-harness-policy | retry budget, quarantine, release hold, repair boundary | GatePolicy, RepairBoundary | 브라우저 selector, API client |
@org/test-harness-agent | targeted rerun, safe edit scope, verification callback | rerunFailedProbe, allowedChangeSet | 제품 코드 직접 수정 규칙의 예외 승인 |
이렇게 나누면 core가 가장 안정적인 계약 계층이 되고,
browser나 state 같은 구현 세부는 자주 바뀌어도 위 계층을 흔들지 않습니다.
하네스 실행 루프
핵심은 probe 자체가 아니라,
entry -> state -> run -> evidence -> classify -> repair/human gate를 끊기지 않게 잇는 구조입니다.
핵심 인터페이스 1: Entry Contract
테스트 하네스의 시작점은 "어떤 계정으로 로그인했는가"보다 넓어야 합니다. gate tier, seed profile, feature flag, 외부 sandbox, 시간 동결 여부까지 들어가야 같은 실행을 다른 세션에서 복원할 수 있습니다.
export type ProbeKind = 'browser' | 'api' | 'workflow' | 'agent-eval'
export type GateTier = 'pr' | 'main' | 'release' | 'nightly'
export type EntryContract = {
runId: string
suite: string
probe: ProbeKind
gate: GateTier
baseUrl: string
seedProfile: 'smoke' | 'critical' | 'billing'
users: Array<{
alias: string
role: 'admin' | 'member' | 'readonly'
}>
flags: Record<string, boolean>
clock?: {
frozenAt: string
}
externalSandbox: {
payments: 'sandbox'
email: 'stub'
webhookReplay: boolean
}
}이 타입은 보통 @org/test-harness-core에 두고,
실제 문서는 YAML이나 JSON으로 저장하되 실행 직전에 이 타입으로 정규화합니다.
핵심 인터페이스 2: State Controller
flake는 상당수가 러너가 아니라 상태 제어 실패에서 나옵니다.
그래서 state-controller.ts는 보조 도구가 아니라 하네스 중심 패키지여야 합니다.
export type EnvFingerprint = {
appRevision: string
schemaRevision: string
seedRevision: string
queueDepth: Record<string, number>
featureFlags: Record<string, boolean>
clock?: string
}
export interface StateController {
prepare(contract: EntryContract): Promise<EnvFingerprint>
reset(scope: 'suite' | 'scenario'): Promise<void>
drainQueue(timeoutMs: number): Promise<void>
fingerprint(): Promise<EnvFingerprint>
}좋은 StateController는 단순히 DB를 지우는 함수가 아닙니다.
prepare -> reset -> fingerprint로 "왜 이 실행이 저번 실행과 달랐는가"를 설명할 수 있어야 합니다.
핵심 인터페이스 3: Evidence Bundle과 Gate Input
하네스가 없을 때 가장 흔한 실패는 artifact는 많은데 정작 판정할 증거가 없는 상태입니다. trace, screenshot, console log를 그냥 저장하지 말고 같은 vocabulary로 묶어야 합니다.
export type FailureClass = 'product' | 'data' | 'infra' | 'external' | 'unknown'
export type EvidenceBundle = {
runId: string
probeId: string
fingerprint: EnvFingerprint
artifacts: {
traceUrl?: string
screenshots: string[]
logs: string[]
events: string[]
}
summary: {
durationMs: number
failedStep?: string
failureClass: FailureClass
hints: string[]
}
}
export type GateDecision =
| { action: 'pass' }
| { action: 'rerun'; reason: string }
| { action: 'quarantine'; reason: string }
| { action: 'hold'; approverGroup: 'release-manager' | 'suite-owner' }여기서 중요한 건 EvidenceBundle이 저장 포맷이면서 동시에 판정 입력이라는 점입니다.
릴리즈 게이트, triage 봇, agent repair loop가 모두 같은 구조를 읽을 수 있어야 합니다.
브라우저 하네스 조립 예시
아래 예시는 @org/test-harness-browser가 @org/test-harness-core,
@org/test-harness-state, @org/test-harness-evidence, @org/test-harness-policy
를 조립하는 방식입니다.
type BrowserRunDeps = {
contract: EntryContract
state: StateController
browser: {
execute(input: {
contract: EntryContract
fingerprint: EnvFingerprint
}): Promise<{
ok: boolean
probeId: string
failedStep?: string
startedAt: string
finishedAt: string
}>
}
evidence: {
collect(input: {
contract: EntryContract
fingerprint: EnvFingerprint
result: {
ok: boolean
probeId: string
failedStep?: string
startedAt: string
finishedAt: string
}
}): Promise<EvidenceBundle>
}
gate: {
evaluate(bundle: EvidenceBundle): GateDecision
}
}
export async function runBrowserHarness(deps: BrowserRunDeps) {
const prepared = await deps.state.prepare(deps.contract)
const result = await deps.browser.execute({
contract: deps.contract,
fingerprint: prepared,
})
const bundle = await deps.evidence.collect({
contract: deps.contract,
fingerprint: prepared,
result,
})
const decision = deps.gate.evaluate(bundle)
if (decision.action === 'rerun') {
await deps.state.reset('scenario')
}
return {
bundle,
decision,
}
}이 예시의 의도는 간단합니다.
browser패키지는 실행을 담당하지만 판정하지 않습니다.state패키지는 환경을 준비하지만 assertion을 모르고 있어야 합니다.evidence패키지는 artifact를 모으지만 rerun 의미를 결정하지 않습니다.policy패키지는 판정하지만 Playwright locator나 API payload를 직접 다루지 않습니다.
이 분리가 깨지면 테스트 하네스는 금방 "큰 util 폴더"가 됩니다.
어떤 하네스가 load-bearing인가
좋은 테스트 하네스는 문서가 많거나 스크립트가 많은 하네스가 아닙니다. 없어지는 순간 실패 반경이 커지는 요소만 남긴 하네스입니다.
| 항목 | load-bearing인 경우 | ritual인 경우 |
|---|---|---|
| seed/reset wrapper | 환경 차이와 제품 회귀를 실제로 분리해줌 | 결국 사람이 DB를 수동으로 맞춤 |
| evidence schema | triage와 postmortem이 같은 vocabulary를 씀 | artifact는 많지만 해석 규칙이 없음 |
| repair boundary | agent가 safe scope 안에서만 재실행됨 | agent가 임의로 gate 의미를 바꿈 |
| gate policy | blocker와 예외 승인 기준이 문서와 실행에 일치 | release 때마다 구두 합의로 바뀜 |
| runner abstraction | browser/API/workflow probe가 같은 lifecycle을 따름 | runner마다 설정과 판단 기준이 따로 놈 |
패키지 경계에서 자주 터지는 실수
| 안티패턴 | 왜 문제인가 | 바로잡는 기준 |
|---|---|---|
core 패키지가 Playwright나 HTTP client를 직접 import | 가장 안정적이어야 할 계약 계층이 실행 도구 변경에 흔들림 | core는 타입·vocabulary·lifecycle만 둠 |
browser 패키지 안에서 flake 분류까지 수행 | 실행과 판정이 섞여 같은 실패를 gate마다 다르게 해석하게 됨 | 분류와 hold/quarantine는 policy로 이동 |
| 공용 fixture가 seed/reset까지 몰래 처리 | 상태 변화가 숨겨져 evidence로 재현 불가 | 상태 제어는 state controller를 통해서만 수행 |
| evidence schema에 버전 정보가 없음 | CI, triage 봇, postmortem 문서가 서로 다른 필드를 기대하게 됨 | bundle schema와 fingerprint field를 버전 관리 |
| agent가 gate policy 파일까지 수정 가능 | self-approve 경로가 생겨 테스트 하네스가 통제력을 잃음 | repair boundary에서 수정 가능 파일을 명시 |
probe별 하네스 차이
| probe | 하네스에서 특히 중요한 것 |
|---|---|
| browser | auth/session, stable locator contract, trace/video, prod-safe 계정 |
| API | idempotent setup, schema contract, response snapshot, tenant isolation |
| workflow | queue drain, event capture, time control, job replay |
| agent eval | model version, prompt/input freeze, tool permission boundary, score rubric |
테스트 하네스 엔지니어링은 브라우저 러너 패턴 하나를 확장하는 게 아니라 probe 종류마다 다른 비결정성을 흡수하는 기술입니다.
agent repair loop와 하네스
agent를 테스트 수정 루프에 넣으려면 하네스가 먼저 아래를 제공해야 합니다.
- 실패 관련 subset만 재실행할 수 있는 targeted rerun
- 환경 fingerprint와 evidence bundle 재수집
- 고위험 변경 시 human gate 분리
- agent가 바꿔도 되는 파일/설정 경계 명시
예를 들어 @org/test-harness-agent는 아래 같은 정책을 읽어야 합니다.
allowedPaths:
- apps/web/tests/**
- packages/test-harness-browser/**
- packages/test-harness-evidence/**
blockedPaths:
- packages/test-harness-policy/**
- infra/production/**
requireHumanApprovalWhen:
- gate == "release"
- failureClass == "unknown"
- changedFiles match "packages/test-harness-state/**"이게 없으면 agent는 "테스트를 고치는 도구"가 아니라 증거를 덮어버리는 불안정한 자동화가 됩니다.
리뷰 체크리스트
@org/test-harness-core가 runner 구현 없이도 독립 설명 가능한가?@org/test-harness-state가 seed/reset/clock/queue/fingerprint를 한 vocabulary로 제공하는가?@org/test-harness-evidence산출물만으로 triage, release hold, postmortem이 가능한가?@org/test-harness-policy가 CI YAML 바깥에서도 재사용 가능한 판정 엔진으로 존재하는가?@org/test-harness-agent가 rerun 권한과 file change scope를 실제 정책으로 강제하는가?- 같은 테스트를 다른 세션에서 다시 실행해도 entry contract가 복원되는가?
- state controller가 clock, queue, sandbox까지 설명할 수 있는가?
- evidence bundle만으로 triage와 release 판단이 가능한가?
- repair boundary가 문서가 아니라 실제 실행 정책으로 연결돼 있는가?
- browser/API/workflow probe가 같은 gate vocabulary를 공유하는가?
관련 핸드북
- 하네스 일반론은 하네스 엔지니어링을 같이 보면 좋습니다.
- 이 책에서는 그 원리를 테스트 환경과 release gate 문맥으로 내려서 다룹니다.