테스트 하네스 엔지니어링
테스트 코드를 넘어 환경 제어, 증거 수집, 재현성, agent repair boundary를 묶는 테스트 하네스 설계 원칙과 패키지 구조
테스트 하네스 엔지니어링은 "테스트를 돌리는 스크립트"를 만드는 일이 아닙니다. 테스트가 신뢰 가능한 증거를 만들도록 환경, 상태, 도구, 판정 경계를 설계하는 일입니다.
이 책에서 말하는 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 문맥으로 내려서 다룹니다.