코드는 에이전트가 씁니다. 당신은 열 문장을 검수합니다.
LLM이 코드를 얼마나 빠르게 써내든, 그 코드가 지금도 스펙대로 동작하는지는 별개의 문제입니다.
Belay는 바이브 코딩을 위한 블랙박스 시나리오 하네스입니다. 검증 대상 프로젝트를
src/에 통째로 두고, 바깥에서 오직 공개 인터페이스만으로 두드려 보는
실행 가능한 스펙을 test/에 쌓습니다. 브라우저의 화면이든 gRPC 스트림이든, 사용자가 닿는 모든 표면이
대상입니다. 텍스트로만 존재하던 스펙이 매번 실제로 돌아가는 계약이 됩니다.
그리고 그 계약은 코드가 아니라 자연어 문장으로 쓰이므로, 한 줄씩 읽고 검수해야 하는
대상은 diff가 아니라 스펙이 됩니다.
.feature
TypeScript Step Definition
HTTP · GraphQL · gRPC · WebSocket
브라우저 제어 · 캡처 · 시각 검증
Mock Infra · 장애 주입 · 시계 제어
언어 · 프레임워크 무관
01 설치와 시작
전역 설치 한 번, belay init 한 번. 그다음부터는 belay run만 반복합니다.
Belay는 전역 CLI로 설치합니다. 검증 대상인 src/가 어떤 언어로 쓰였든 상관없기 때문에,
하네스와 대상의 런타임은 서로 섞이지 않습니다. Node는 Belay를 돌리기 위한 것이지 프로젝트의 제약이 아닙니다.
요구 사항
| 항목 | 필요 여부 | 비고 |
|---|---|---|
| Node.js 22+ | 필수 | Belay CLI와 step definition 실행용. src/와 무관 |
| Docker | 권장 | mock infra와 SUT 컨테이너 기동. 없으면 로컬 프로세스 모드로 동작 |
| 브라우저 엔진 | 선택 | 브라우저 표면을 선택했을 때 init이 필요한 것만 내려받음 |
설치
터미널npm install -g @beoks/belay
belay --version
belay doctor # 포트·Docker·엔진 상태를 미리 점검
초기화
빈 디렉터리에서 시작하든 이미 굴러가는 프로젝트에서 시작하든 명령은 같습니다.
init은 현재 디렉터리를 살펴 감지한 것을 기본값으로 제안합니다.
mkdir my-project && cd my-project
belay init
# 엔터를 누르면 대괄호 안의 기본값이 쓰입니다.
# 감지됨: npm --prefix src start
검증할 표면 (쉼표로 구분: http, graphql, ws, browser, cli)
[http] > http, ws
src 를 실행하는 명령
[npm --prefix src start] >
준비 완료를 판정할 URL
[http://localhost:8080/health] >
외부 API 스텁을 만들까요? (Docker 불필요, 인메모리)
[y/N] > y
메일 캡처 서버를 만들까요? (SMTP)
[y/N] > y
✓ src/ # 없으면 만들어 둡니다 — 검증 대상이 사는 곳
✓ belay.config.ts # 선택한 표면과 인프라가 채워진 상태로
✓ test/support/world.ts
✓ test/support/steps/common.steps.ts
✓ test/features/L0-smoke/health.feature # 첫 확보 지점
✓ test/features/L0-smoke/health.steps.ts
✓ AGENTS.md # 에이전트가 test/ 를 고치지 못하게 하는 규칙
✓ .github/workflows/belay.yml
✓ .gitignore
✓ package.json # test · test:smoke · test:watch 스크립트
다음 단계
1. src/ 에 검증할 프로젝트를 두고 belay.config.ts 의 app.start 를 확인하세요
2. belay run --only L0 # 첫 확보 지점을 통과시키세요
3. belay new L1-<도메인>/<기능> # 스펙을 시나리오로 고정하세요
생성되는 것
my-project/
├── belay.config.ts # 선택한 표면·인프라가 채워진 상태로 생성
├── AGENTS.md # 에이전트용 규칙 — test/ 를 고치지 못하게 하는 문장 포함
├── src/ # 기존 코드가 있었다면 통째로 이동
└── test/
├── support/ # world · hooks · 선택한 표면의 드라이버만
├── contracts/ # GraphQL/gRPC 를 골랐다면 스키마 자리
├── baselines/ # 브라우저를 골랐을 때만 생성
└── features/
└── L0-smoke/
└── health.feature
생성되는 첫 시나리오는 의도적으로 사소합니다.
test/features/L0-smoke/health.feature# language: ko
기능: 서비스 기동
시나리오: 헬스체크에 응답한다
만일 "/health" 를 조회하면
그러면 응답은 성공이다
src/가 빌드되고, 준비 판정이 통과하고, 시나리오가 실행되고, 정리까지 끝났다는 것 —
나머지 시나리오는 전부 이 위에 쌓입니다. 로프를 걸기 전에 앵커가 박혔는지부터 확인하는 것과 같습니다.
명령어
| 명령 | 하는 일 |
|---|---|
belay init | 설정과 test/ 뼈대 생성. 기존 프로젝트에도 안전하게 덧씌움 |
belay run | 전체 실행 — 인프라 기동부터 teardown까지 |
belay run --upto L1 | 계층을 잘라 빠르게 실행 (--only L2도 가능) |
belay run --grep "회원가입" | 제목으로 시나리오 필터링 (--tags "@smoke"도 가능) |
belay run --watch | 변경 시 재실행. 환경은 유지되므로 시나리오 편집은 밀리초 단위로 반영 |
belay run --bail | 첫 실패에서 중단 |
belay up | 환경만 띄우고 포그라운드로 유지. 손으로 두드려 볼 때 씁니다 |
belay down | 실패 후 유지된 환경 정리 (자식 프로세스 · 컨테이너) |
belay new L2-order/checkout | .feature와 짝 step 파일을 함께 생성 |
belay approve cart-empty | 시각 baseline 승인. diff를 보여주고 확인을 받은 뒤에만 갱신 |
belay report --open | 최근 실행 리포트 열기 |
belay doctor | 포트 점유, Docker, 브라우저 엔진, 고아 컨테이너 점검 |
언어
메시지 · CLI 도움말 · 생성되는 스캐폴딩이 모두 BELAY_LANG
(en, ko)을 따릅니다. 값이 없으면 로케일을, 그것도 없으면 영어를 씁니다.
belay init # 영어 Gherkin · 스텝 · AGENTS.md
BELAY_LANG=ko belay init # 한국어로 생성
Gherkin 파서는 이 설정과 무관하게 ko · en · ja dialect 를
모두 읽습니다. 한국어로 쓴 시나리오를 영어 도구를 보는 사람이 그대로 돌릴 수 있고,
저장소의 예제도 두 언어가 섞여 있어 양쪽 dialect 가 매 CI 실행에서 검증됩니다.
에이전트에게 넘기기
init이 AGENTS.md에 넣는 규칙은 짧습니다. 핵심은 하나 —
채점자와 응시자를 분리하는 것입니다.
## Belay 하네스 규칙
- 구현은 `src/` 안에서만 수정합니다.
- `test/` 는 읽기 전용입니다. 시나리오를 고쳐서 통과시키지 마세요.
- 작업을 마쳤다고 보고하기 전에 `belay run` 을 실행하고 출력을 그대로 첨부하세요.
- 스펙이 잘못되었다고 판단되면 고치지 말고, 근거와 함께 보고만 하세요.
여기까지 오면 반복 구간은 세 줄로 줄어듭니다. 에이전트가 src/를 고치고,
belay run이 판정하고, 실패 목록이 다음 할 일이 됩니다.
채점 기준을 스스로 고칠 수 없으므로 구현만 수렴합니다 — 이것이 Belay가 말하는 안전한 바이브 코딩입니다.
belay run --only L0 # 앵커 확인
belay new L1-account/signup # 스펙을 시나리오로 먼저 고정
belay run # 실패 목록 = 남은 할 일
02 이미 있는 프로젝트 채택하기
안전망이 가장 절실한 프로젝트는 이미 굴러가고 있는 프로젝트입니다.
Belay는 빈 디렉터리에서만 시작할 수 있는 것이 아닙니다. 이미 돌고 있는 서비스를 가리키면, 코드를 읽어서 원래는 처음부터 설명해야 했을 부분을 채워 넣습니다.
terminalbelay init --from ../my-api --link symlink
✓ src → ../my-api
프로젝트에서 읽은 것
stack node
start npm start --prefix src (high: scripts.start = "node src/index.js")
port 4321 (high: PORT=4321)
health /healthz
surfaces http
infra db · cache (compose 에서 — 필요한 것만 선언하세요)
포트와 실행 명령, health 경로는 모두 코드에서 나옵니다. 그래서 첫 시나리오는 이 서비스가 따르지 않을지도 모르는 관례가 아니라, 실제로 응답하는 엔드포인트를 두드립니다. 읽어 낸 값에는 어디서 나왔는지가 함께 붙습니다 — 확인할 수 있는 제안이 믿어야 하는 제안보다 낫습니다.
src/를 연결하는 방식
네 가지 방식은 연결한 뒤 코드의 소유가 누구에게 있는지가 다르고, 그에 따라 CI가 스위트를 체크아웃할 수 있는지까지 갈립니다. 남을 대신해 정해 줄 선택이 아닙니다.
--link | 동작 | 이럴 때 |
|---|---|---|
symlink | 프로젝트는 원래 자리에 그대로 있고, 수정이 즉시 반영됩니다 | 아직 로컬에서 그 프로젝트를 작업하는 중일 때 |
submodule | 커밋을 고정하므로, 실행 결과가 어느 리비전에서 통과한 것인지 말해 줍니다 | 공용 레포이거나, CI가 체크아웃해야 할 때 |
move | 하네스가 그 프로젝트의 레포가 됩니다 | 레포를 하나로 두고 싶을 때 |
copy | 스냅숏이므로 원본과 점점 벌어집니다 | 고정된 참조본이 필요할 때 |
symlink로 연결한 src는 .gitignore에 추가됩니다. 레포 바깥을 가리키는 링크를
커밋해서 득 볼 사람이 없습니다. submodule은 커밋되며, CI에서는 actions/checkout@v4에
submodules: true가 필요합니다.
이미 있는 코드를 시나리오로 만들기
belay analyze는 연결된 프로젝트를 읽어 .belay/analysis.md에 체크리스트를
씁니다. 찾아낸 엔드포인트를 L1- 디렉터리가 될 도메인 단위로 묶고, 각각에 성공 경로 후보와
거부 경로 후보를 하나씩 달아 둡니다.
### `L1-orders`
| 표면 | 오퍼레이션 | 위치 |
|---|---|---|
| http | `GET /api/orders` | src/routes/orders.js |
| http | `POST /api/orders` | src/routes/orders.js |
| http | `ANY /api/orders/:param` | src/routes/orders.js |
후보:
- [ ] `POST /api/orders` — 성공 경로: 이후에 참이 되는, 이전에는 아니었던 것은 무엇인가?
- [ ] `POST /api/orders` — 거부 경로: 어떤 입력이 거부되고, 어떤 오류 코드로 거부되는가?
POST /api/orders를 나열할 수 있지만, 주문을 넣을 때 고객에게 두 번 청구해서는
안 된다는 사실은 알려 주지 못합니다. 그 문장은 사람에게서 나와야 합니다. 아닌 척하는 도구는 완성된 것처럼
보이면서 실제로는 아무것도 검증하지 않는 스위트를 건네게 됩니다.
작업을 맡을 에이전트를 위한 skill
init은 같은 skill을 .claude/skills/belay/와
.agent/skills/belay/에 함께 씁니다. 코딩 에이전트가 시작하기 전에 경계를 먼저 읽게
하려는 것입니다 — src/는 다시 써도 되지만, 무언가를 통과시키려고 test/를
고치는 것은 안 됩니다. Belay의 가치는 전부 그 분리 위에 서 있고, 에이전트는 읽으라고 시키지도 않은
README만으로는 그 규칙을 지키지 않습니다.
belay run --only L0을
먼저 돌리십시오. 하네스가 아직 프로젝트를 띄우지 못하는 상태라면, 이후의 모든 실패는 코드가 아니라
그 문제에 관한 이야기가 됩니다.
03 왜 Belay 인가
바이브 코딩의 진짜 병목은 생산 속도가 아니라 회귀에 대한 두려움, 그리고 에이전트가 쓴 코드를 읽는 비용입니다.
에이전트에게 기능을 맡기면 코드는 나옵니다. 문제는 그다음입니다. 리팩터링을 시켜도 되나? 프레임워크를 갈아타도 되나? 이 커밋이 두 달 전에 만든 결제 흐름을 조용히 깨뜨리지는 않았나? 확인할 방법이 없으면 결국 사람이 매번 손으로 눌러 보게 되고, 속도의 이점은 거기서 사라집니다.
기존 방식들은 각자 다른 이유로 이 지점에서 미끄러집니다.
문서로만 존재하는 SDD
스펙 문서는 실행되지 않습니다. 구현이 문서를 배신해도 아무도 알려주지 않고, 시간이 지나면 문서가 먼저 썩습니다.
내부 구조에 붙은 유닛 테스트
함수 시그니처와 클래스 이름에 결합됩니다. 구조를 바꾸는 순간 테스트부터 무너지므로, 정작 리팩터링을 막는 족쇄가 됩니다.
수동 QA
재현 가능하지 않고 기록도 남지 않습니다. 커밋마다 돌릴 수 없다면 안전망이라 부를 수 없습니다.
실행되는 스펙, 구현과 분리된 위치
시나리오는 src/ 바깥에 삽니다. 안쪽을 통째로 갈아엎어도 시나리오는 그대로 남아 정답지 역할을 합니다.
병목의 나머지 절반: 코드를 읽는 일
바이브 코딩은 코드를 쓰는 비용만 낮췄을 뿐, 그 코드를 믿는 비용은 오히려 올려놨습니다. 에이전트는 500줄짜리 diff를 1분 만에 내놓고, 그것을 눈으로 승인하려면 500줄을 전부 읽어야 합니다. 다음 커밋에서도, 그다음 커밋에서도 마찬가지입니다. 검수해야 할 산출물이 우리를 빠르게 만들어 준 바로 그 속도로 함께 늘어납니다.
Belay는 대신 훨씬 작고, 이미 읽을 줄 아는 언어로 쓰인 산출물을 건넵니다.
test/features/L2-cart/coupon.feature 시나리오: 이미 사용한 쿠폰은 다시 쓸 수 없다
먼저 쿠폰 "WELCOME" 은 이미 사용되었다
만일 그 쿠폰을 장바구니에 적용하면
그러면 요청은 거절된다
그리고 장바구니 결제 금액은 그대로다
검수 대상은 이것입니다. 프레임워크도, 클래스 이름도, 제어 흐름도 없고 — 우리가 합의하려는 동작만 남습니다. 문장 열 줄이 구현 천 줄이 해도 되는 일의 범위를 못 박고, 애초에 그 기능을 요구한 사람도 그대로 읽을 수 있습니다.
게다가 이 문장들은 구현에 대한 설명이 아니라 구현에 대한 검사입니다. 스텝은 사용자가 닿을 수 있는 표면만 두드리므로, 스위트가 초록색이라는 건 그 문장들이 실제로 돌아가는 시스템에서 참이라는 뜻입니다. 무언가를 목으로 발라 맞춰 놓은 결과가 아닙니다. 스펙을 검수하고, 구현 검수는 스위트에 맡기십시오.
04 핵심 개념
등반자는 자유롭게 오르고, 확보자는 로프를 잡습니다.
등반자 = src/
에이전트가 마음껏 오르는 곳. 언어도 프레임워크도 아키텍처도 자유롭게 바뀔 수 있습니다.
확보자 = test/
바깥에서 로프를 잡습니다. 안쪽 구조를 모르고, 알 필요도 없습니다. 떨어지는 순간만 잡아냅니다.
앵커 = 루트 실행 정의
무엇을 어떻게 띄우고 어떻게 두드릴지 고정하는 지점. 이게 박혀 있어야 로프가 의미를 갖습니다.
여기서 따라 나오는 세 가지 원칙이 Belay의 전부입니다.
- 블랙박스 — 테스트는 배포된 산출물이 노출하는 인터페이스로만 대상을 다룹니다. 소스 임포트, 내부 함수 호출, 내부 DB 직접 조작 모두 금지.
- 재현 가능 — 인프라 기동부터 정리까지 한 명령에 담깁니다. 내 노트북에서 되는 것과 CI에서 되는 것이 같아야 합니다.
- 스펙이 곧 테스트 —
.feature는 사람이 읽는 명세인 동시에 기계가 돌리는 검증입니다. 둘이 갈라질 여지를 없앱니다.
05 프로젝트 구조
최상단은 언제나 src와 test 둘뿐입니다.
# 프로젝트 루트
Belay/
├── belay.config.ts # 앵커: src를 어떻게 띄우고 두드릴지
├── docs/
│ └── index.html
│
├── src/ # ── 검증 대상. 별도 레포의 루트와 동일한 모습 ──
│ ├── package.json # Node? Kotlin? Go? Rust? 무엇이든 상관없음
│ ├── Dockerfile
│ └── ... # Belay는 이 안을 들여다보지 않는다
│
└── test/ # ── 블랙박스 시나리오 ──
├── support/
│ ├── world.ts # 시나리오별 컨텍스트 (드라이버, 상태)
│ ├── hooks.ts # Before / After, 시나리오 간 격리
│ ├── steps/ # 프로토콜 공통 스텝 (상태코드, JSONPath 단언 등)
│ └── drivers/ # Surface 어댑터 — 도메인 로직 없음
│ ├── http.ts
│ ├── graphql.ts
│ ├── grpc.ts
│ ├── ws.ts # WebSocket / SSE 등 스트리밍
│ ├── browser.ts # 페이지 조작 · 캡처 · 시각 검증
│ ├── cli.ts
│ └── mocks.ts # mock infra 조작 · 수신 호출 검증
│
├── contracts/ # 프로토콜 스키마 — 드라이버가 타입을 얻는 곳
│ ├── schema.graphql
│ ├── order.proto
│ └── pg.openapi.yaml # 외부 결제사 스텁의 계약
│
├── baselines/ # 승인된 스크린샷 — 변경은 리뷰 대상
│ └── L2-checkout/
│ ├── cart-empty.desktop.png
│ └── cart-empty.mobile.png
│
└── features/
├── L0-smoke/
│ └── health.feature
├── L1-account/
│ ├── signup.feature # HTTP
│ ├── signup.steps.ts
│ └── login.feature
├── L1-catalog/
│ ├── search.feature # GraphQL
│ └── search.steps.ts
├── L2-order/
│ ├── checkout.feature # gRPC + WebSocket
│ └── checkout.steps.ts
├── L2-ui-checkout/
│ ├── cart.feature # 브라우저 여정 + 시각 검증
│ └── cart.steps.ts
└── L3-cross/
└── signup-to-first-order.feature
| 경로 | 역할 | 규칙 |
|---|---|---|
src/ | 검증 대상 프로젝트 | 내용에 어떤 제약도 없음. 통째로 교체 가능 |
test/features/ | 시나리오 계층 | 디렉터리가 곧 계층. .feature와 짝 step 파일을 같은 폴더에 |
test/support/ | 드라이버·컨텍스트 | 공개 인터페이스 어댑터만. 도메인 로직 금지 |
test/contracts/ | 프로토콜 스키마 | GraphQL SDL, .proto, OpenAPI. 드라이버 타입의 출처 |
test/baselines/ | 승인된 스크린샷 | 커밋 대상. 갱신은 리뷰를 거친 명시적 승인으로만 |
belay.config.ts | 실행 계약 | 인프라·기동·준비확인·표면·리포트·정리 정의 |
06 실행 계약
루트 설정 파일 하나가 "이 프로젝트를 테스트 가능한 상태로 만드는 법"을 전부 담습니다.
Belay가 src/에 대해 아는 유일한 것은 이 파일입니다. 어떻게 빌드하고, 무엇에 의존하고,
언제 준비된 것으로 볼지, 그리고 어떤 창구로 두드릴지. 여기 적히지 않은 내부 사정은 테스트가 알 수 없습니다 — 그게 의도입니다.
import { defineConfig } from '@beoks/belay';
export default defineConfig({
// 1. src가 필요로 하는 주변 환경 — 컨테이너, mock 서버, 무엇이든
infra: [
{ name: 'postgres', image: 'postgres:16', ports: ['5432'],
env: { POSTGRES_PASSWORD: 'test' },
ready: { tcp: '5432' } },
{ name: 'payment-gateway', // 외부 결제사 스텁
command: 'npx prism mock ./test/contracts/pg.openapi.yaml -p 4010',
ready: { http: 'http://localhost:4010/__health' } },
],
// 2. src를 띄우는 법. 명령어 한 줄이면 충분하다
app: {
build: 'docker build -t belay-sut ./src',
start: 'docker run --rm -p 8080:8080 --env-file .belay/env belay-sut',
env: {
DATABASE_URL: 'postgres://postgres:test@localhost:5432/app',
PAYMENT_BASE_URL: 'http://localhost:4010',
},
// 준비 완료 판정. 이게 없으면 테스트는 항상 경합한다
ready: { http: 'http://localhost:8080/health', timeoutMs: 60_000 },
},
// 3. 테스트가 대상을 두드릴 수 있는 유일한 창구들
// 여기 선언된 것만 존재한다. 선언되지 않은 경로로 닿으면 블랙박스가 깨진다.
surfaces: {
http: { baseUrl: 'http://localhost:8080' },
graphql: { endpoint: 'http://localhost:8080/graphql',
schema: './test/contracts/schema.graphql' },
grpc: { address: 'localhost:9090',
proto: './test/contracts/order.proto', reflection: false },
ws: { url: 'ws://localhost:8080/ws', subprotocol: 'graphql-ws' },
queue: { kind: 'kafka', brokers: ['localhost:9092'] },
cli: { command: 'docker run --rm belay-sut app-cli' },
// 프론트엔드 표면 — 사용자가 실제로 보는 화면
browser: { baseUrl: 'http://localhost:3000',
engine: 'chromium',
viewports: { desktop: [1280, 800], mobile: [390, 844] },
locale: 'ko-KR', timezone: 'Asia/Seoul', colorScheme: 'light' },
},
// 4. 시각 검증 정책
visual: {
baselineDir: 'test/baselines',
diff: { threshold: 0.01, ignore: ['[data-belay-volatile]'] },
interpret: { enabled: true, gate: false }, // 시각 해석은 기본 비게이트 — 10장 참조
capture: { onFailure: 'screenshot+dom+video' },
},
// 5. 환경 제어 — 바깥에서 만들 수 없는 상태를 만드는 선언된 창구
control: {
clock: { via: 'http', endpoint: '/__test/clock' }, // SUT가 제공하는 테스트 전용 계약
seed: { via: 'http', endpoint: '/__test/seed' },
faults: { targets: ['postgres', 'payment-gateway'] }, // 지연·단절·재시작 주입 대상
determinism: { randomSeed: 1337, freezeTimezone: 'Asia/Seoul' },
},
// 6. 시나리오 위치
suite: {
features: 'test/features/**/*.feature',
steps: 'test/**/*.steps.ts',
support: 'test/support/**/*.ts',
},
// 7. 시나리오 사이 격리 — 상태 누수는 신뢰를 갉아먹는다
isolation: {
between: 'scenario',
// 셸 명령이거나, 선언된 창구로 보내는 요청. 둘 다 SUT 내부를 건드리지 않는다.
reset: { http: '/__test/seed', body: { data: { todos: [] } } },
browser: true, // 쿠키·스토리지·세션까지 매번 새 컨텍스트
mocks: true, // 스텁 프로그래밍과 수신 기록을 함께 초기화
},
// 8. 데드라인 — 멈춘 스텝 하나가 실행 전체를 인질로 잡지 못하게
timeouts: { step: 30_000, scenario: 120_000, hook: 30_000 },
// 9. 결과와 정리
report: { formats: ['html', 'junit', 'json'], outDir: '.belay/reports', trace: true },
teardown: { keepOnFailure: true },
});
belay run — 사람이 치든 CI가 치든 에이전트가 치든 같은 명령,
같은 순서, 같은 결과. 준비 절차가 사람 머릿속에 남아 있으면 그 테스트는 신뢰 대상이 아닙니다.
07 실행 라이프사이클
belay run 한 번에 벌어지는 일 — 올라가고, 확보하고, 내려옵니다.
belay.config.ts를 읽고 포트 충돌, 필수 도구, 남은 이전 실행 잔재를 확인합니다. 여기서 실패하면 아무것도 띄우지 않습니다.ready 조건을 만족할 때까지 대기합니다.app.build → app.start. 주입되는 환경변수는 방금 띄운 인프라를 가리킵니다. src는 자기가 테스트 중인지 알지 못합니다.app.ready가 통과해야 다음으로 넘어갑니다. 타임아웃 시 부팅 로그를 그대로 붙여 실패 처리 — "그냥 느렸던 것"과 "안 뜬 것"을 구분합니다..feature를 실행합니다. 각 시나리오마다 상태 초기화·mock 스텁 리셋·새 브라우저 컨텍스트가 준비되고, 모든 프로토콜 교환이 트레이스에 기록됩니다..belay/reports에 남깁니다.keepOnFailure가 켜져 있으면 실패한 실행의 컨테이너는 살려 두어 사후 조사에 씁니다.08 시나리오 작성
Gherkin으로 씁니다. 사람이 읽는 명세와 기계가 돌리는 검증이 같은 문장이고, 그래서 src/를 한 번도 열지 않는 사람도 스펙을 검수할 수 있습니다.
# language: ko
기능: 이메일 회원가입
등록되지 않은 이메일로 가입을 요청하면 계정이 생성되고
인증 메일이 발송된다. 이미 쓰인 이메일은 거절한다.
배경:
먼저 메일 발송 스텁이 비어 있다
시나리오: 신규 이메일로 가입에 성공한다
먼저 "chulsoo@example.com" 은 등록되지 않은 이메일이다
만일 "chulsoo@example.com" 으로 회원가입을 요청하면
그러면 응답 상태코드는 201 이다
그리고 응답 본문에 계정 식별자가 포함된다
그리고 "chulsoo@example.com" 으로 인증 메일이 1건 발송된다
시나리오: 이미 사용 중인 이메일은 거절한다
먼저 "chulsoo@example.com" 으로 가입된 계정이 있다
만일 "chulsoo@example.com" 으로 회원가입을 요청하면
그러면 응답 상태코드는 409 이다
그리고 인증 메일은 발송되지 않는다
시나리오 개요: 잘못된 형식의 이메일은 거절한다
만일 "<입력>" 으로 회원가입을 요청하면
그러면 응답 상태코드는 400 이다
예:
| 입력 |
| not-an-email |
| @example.com |
| |
좋은 시나리오의 조건
관찰 가능한 것만 말한다
"응답 상태코드는 409 이다", "인증 메일이 1건 발송된다" — 바깥에서 확인 가능한 사실.
구현을 말한다
"UserService.create 가 호출된다", "users 테이블에 row가 생긴다" — 구조를 바꾸면 깨지는 서술.
시나리오는 스스로 완결된다
앞선 시나리오의 잔여 상태에 기대지 않습니다. 순서를 섞어 돌려도 같은 결과여야 합니다.
실패 경로를 함께 쓴다
성공 경로만 있는 스펙은 포팅할 때 가장 위험합니다. 거절·중복·타임아웃을 같이 고정하세요.
09 Step Definition
TypeScript로만 작성합니다. src가 어떤 언어든, 확보자의 언어는 하나로 고정합니다.
스텝 구현은 얇아야 합니다. 문장을 드라이버 호출로 번역하고 응답을 단언하는 것까지가 전부이고, 도메인 규칙을 여기에 다시 구현하기 시작하면 테스트가 두 번째 구현체가 되어 버립니다.
test/features/L1-account/signup.steps.tsimport { Given, When, Then, expect } from '@beoks/belay';
import type { AppWorld } from '../../support/world';
Given('{string} 은 등록되지 않은 이메일이다', async function (this: AppWorld, email: string) {
// 공개 API로만 확인한다. DB를 직접 뒤지지 않는다.
const res = await this.http.get(`/api/v1/accounts?email=${encodeURIComponent(email)}`);
expect(res.status).toBe(404);
this.ctx.email = email;
});
Given('{string} 으로 가입된 계정이 있다', async function (this: AppWorld, email: string) {
const res = await this.http.post('/api/v1/accounts', { email, password: 'P@ssw0rd!' });
expect(res.status).toBe(201);
this.ctx.email = email;
await this.mock.mail.clear(); // 준비 과정의 부수효과는 관찰 대상이 아니다
});
When('{string} 으로 회원가입을 요청하면', async function (this: AppWorld, email: string) {
this.last = await this.http.post('/api/v1/accounts', { email, password: 'P@ssw0rd!' });
});
Then('응답 상태코드는 {int} 이다', function (this: AppWorld, status: number) {
expect(this.last.status).toBe(status);
});
Then('{string} 으로 인증 메일이 {int}건 발송된다',
async function (this: AppWorld, to: string, count: number) {
// 메일 스텁은 우리 것 — 수신함을 직접 확인해도 블랙박스는 유지된다
const mails = await this.mock.mail.waitFor({ to, timeoutMs: 5_000 });
expect(mails).toHaveLength(count);
});
test/support/world.ts
import { World, setWorldConstructor } from '@beoks/belay';
export class AppWorld extends World {
// World 는 이미 표면별 드라이버를 갖고 있습니다:
// this.http · this.gql · this.ws · this.cli · this.page
// this.mock.<이름> — 선언한 stub / mail
// this.control — clock · seed · faults
// this.last — 마지막 Exchange (프로토콜 공통 스텝이 참조)
// 설정에 없는 표면에 접근하면 무엇을 선언해야 하는지 알려주며 실패합니다.
// 여기에는 시나리오 안에서만 유효한 상태와 얇은 헬퍼만 둡니다.
token?: string;
async authenticate(email: string, password = 'P@ssw0rd!'): Promise<void> {
const response = await this.http.post('/api/v1/sessions', { email, password });
this.token = (response.body as { token?: string })?.token;
}
}
setWorldConstructor(AppWorld);
surfaces 밖으로 나가지 않습니다.
스텝 코드에서 src/를 임포트하거나 컨테이너 내부를 exec하기 시작하면
그 순간 블랙박스가 아니게 되고, 포팅했을 때 통과를 보장할 수 없게 됩니다.
10 프로토콜 드라이버
HTTP · GraphQL · gRPC · WebSocket — 표면은 달라도 시나리오 문장은 같은 모양이어야 합니다.
백엔드는 하나의 프로토콜만 쓰지 않습니다. 조회는 GraphQL, 내부 호출은 gRPC, 실시간 갱신은 WebSocket,
관리 기능은 REST인 서비스가 흔합니다. 프로토콜마다 단언 방식이 제각각이면 시나리오는 금세 기술 문서가 되고,
src/를 갈아엎을 때 같이 무너집니다.
그래서 Belay의 모든 드라이버는 동일한 Exchange를 반환합니다.
상태·본문·메타데이터가 같은 모양으로 정규화되므로, 상태코드나 JSONPath 단언 같은 공통 스텝을
프로토콜을 넘나들며 재사용할 수 있습니다.
| 프로토콜 | 드라이버 호출 | 시나리오가 관찰하는 것 |
|---|---|---|
| HTTP | this.http.post(path, body) | 상태코드, 헤더, 본문, 응답 시간 |
| GraphQL | this.gql.query(doc, vars) | data 형태, errors[].extensions.code, 부분 성공 |
| gRPC | this.grpc.call('svc/Method', msg) | status code, trailer, 응답 메시지 |
| gRPC 스트림 | this.grpc.stream(...) | 메시지 순서, 종료 상태, 백프레셔 동작 |
| WebSocket | this.ws.connect() → waitFor() | 수신 메시지, 순서, 재연결, close code |
| Queue | this.queue.publish(topic, msg) · consume(topic) → waitFor() | 발행된 메시지, key 와 헤더, 순서, 오지 않아야 하는 것 (quietFor) |
| Queue | this.queue.consume(topic) | 발행된 이벤트, 키, 중복/순서 보장 |
| CLI | this.cli.run(args) | 종료 코드, stdout/stderr |
정규화된 교환 결과
test/support/drivers/types.tsexport interface Exchange<T = unknown> {
ok: boolean;
status: number; // HTTP status · gRPC code · WS close code로 정규화
body: T; // GraphQL은 data, gRPC는 응답 메시지
errors?: { code: string; message: string; path?: string[] }[];
meta: Record<string, string>; // 헤더 · trailer · 프레임 메타
elapsedMs: number;
// 모든 Exchange는 자동으로 트레이스에 기록된다 — 별도 로깅 불필요
}
덕분에 이런 스텝을 한 번만 정의해 전 프로토콜에서 씁니다.
test/support/steps/common.steps.tsThen('응답은 성공이다', function (this: AppWorld) {
expect(this.last.ok).toBe(true);
});
Then('오류 코드는 {string} 이다', function (this: AppWorld, code: string) {
expect(this.last.errors?.[0].code).toBe(code); // GraphQL · gRPC · HTTP 공통
});
Then('응답의 {string} 는 {string} 이다',
function (this: AppWorld, path: string, value: string) {
expect(jsonPath(this.last.body, path)).toEqual(coerce(value));
});
프로토콜별 스텝 구현
test/features/L2-order/checkout.steps.ts// ── GraphQL ─────────────────────────────────────────────
When('{string} 로 상품을 검색하면', async function (this: AppWorld, q: string) {
this.last = await this.gql.query(`
query Search($q: String!) { search(query: $q) { id name price } }
`, { q }); // contracts/schema.graphql 로 타입 검증됨
});
// ── gRPC (unary) ────────────────────────────────────────
When('주문을 확정하면', async function (this: AppWorld) {
this.last = await this.grpc.call('order.OrderService/PlaceOrder', {
cartId: this.ctx.cartId, idempotencyKey: this.ctx.key,
});
});
Then('gRPC 상태는 {string} 이다', function (this: AppWorld, code: string) {
expect(this.last.meta['grpc-status-name']).toBe(code); // FAILED_PRECONDITION 등
});
// ── WebSocket ───────────────────────────────────────────
Given('주문 상태 채널을 구독하고 있다', async function (this: AppWorld) {
this.sub = await this.ws.subscribe({ topic: 'order', id: this.ctx.cartId });
});
Then('{int}초 안에 주문 상태 {string} 알림을 받는다',
async function (this: AppWorld, sec: number, status: string) {
// sleep이 아니라 조건 대기 — 스트리밍에서 고정 대기는 곧 플레이키다
const msg = await this.sub.waitFor(
(m) => m.type === 'order.status' && m.status === status,
{ timeoutMs: sec * 1000 },
);
expect(msg.orderId).toBe(this.last.body.orderId);
});
Then('그 외 주문 알림은 오지 않는다', async function (this: AppWorld) {
await expect(this.sub.quietFor({ ms: 1_000 })).resolves.toBe(true);
});
시나리오에서는 프로토콜의 흔적이 거의 드러나지 않습니다.
test/features/L2-order/checkout.feature# language: ko
기능: 주문 확정
시나리오: 재고가 있으면 주문이 확정되고 실시간으로 통보된다
먼저 "노트북 거치대" 가 3개 재고로 등록되어 있다
그리고 주문 상태 채널을 구독하고 있다
만일 주문을 확정하면
그러면 응답은 성공이다
그리고 5초 안에 주문 상태 "CONFIRMED" 알림을 받는다
그리고 그 외 주문 알림은 오지 않는다
시나리오: 재고가 부족하면 주문을 거절한다
먼저 "노트북 거치대" 가 0개 재고로 등록되어 있다
만일 주문을 확정하면
그러면 gRPC 상태는 "FAILED_PRECONDITION" 이다
그리고 오류 코드는 "OUT_OF_STOCK" 이다
Exchange 정규화는 구현되어 예제에서 매 실행 검증됩니다
(gRPC 는 examples/04-grpc — unary · 오류 코드 · 서버 스트리밍).
gRPC 표면은 @grpc/grpc-js 와 @grpc/proto-loader 를,
queue 표면은 kafkajs 를 선택적 peer 의존성으로 필요로 합니다.
queue 드라이버는 admin API 로 없는 토픽을 만들어 주므로 브로커의 auto-create 설정에
의존하지 않습니다. 검증 상태는 17장에 정직하게 적혀 있습니다 — 실제 브로커로 현장 검증,
예제에 의한 상시 검증은 아님.
waitFor로 기다리고, "오지 않아야 한다"는
quietFor로 명시하세요. sleep(3000)은 느린 CI에서 깨지고 빠른 머신에서는 시간을 버립니다.
11 브라우저와 시각 검증
프론트엔드의 공개 인터페이스는 화면입니다. 그래서 화면을 열고, 누르고, 찍고, 읽습니다.
프론트엔드 프로젝트에서 "블랙박스"는 곧 브라우저입니다. Belay는 실제 엔진을 띄워 사용자가 하는 그대로 조작하고, 결과를 세 가지 층위로 검증합니다. 층위마다 결정성이 다르고, 게이트로 삼을 수 있는 범위도 다릅니다.
1 · 구조 단언
접근성 트리 기준으로 역할·이름·상태를 확인합니다. 완전히 결정적이므로 통과/실패의 1차 근거는 항상 여기입니다.
2 · 시각 회귀
승인된 baseline 스크린샷과 perceptual diff. 임계치와 무시 영역이 선언되어 있어 재현 가능하고, 변경은 리뷰로 승인합니다.
3 · 시각 해석
캡처한 이미지를 모델이 읽고 "범례가 겹치지 않는다" 같은 판단을 냅니다. baseline이 없는 영역을 잡아내지만 비결정적입니다.
examples/05-browser 에서 검증됩니다 — 의미 기반 선택자, 캡처,
baseline 비교까지. 시각 게이트가 실제로 동작하는지도 확인했습니다:
버튼 색 한 줄을 바꾸자 3.33% 차이로 실패했습니다.
playwright(엔진)와 pngjs·pixelmatch(픽셀 비교)를
선택적 의존성으로 설치해야 합니다:
npm i -D playwright pngjs pixelmatch && npx playwright install chromium
visual.interpret.gate를 켜되, 판정 근거와 모델 응답이 리포트에 함께 기록됩니다.
시나리오
test/features/L2-ui-checkout/cart.feature# language: ko
기능: 장바구니 화면
배경:
먼저 "chulsoo@example.com" 으로 로그인한 상태로 "/cart" 를 연다
시나리오: 담은 상품이 없으면 빈 상태를 안내한다
그러면 "장바구니가 비어 있습니다" 문구가 보인다
그리고 "주문하기" 버튼은 비활성 상태다
그리고 화면 "cart-empty" 는 승인된 기준 이미지와 같다
시나리오: 수량을 바꾸면 합계가 즉시 갱신된다
먼저 장바구니에 "노트북 거치대" 가 1개 담겨 있다
만일 "수량" 을 3 으로 바꾸면
그러면 "합계" 영역에 "87,000원" 이 보인다
그리고 콘솔 오류가 발생하지 않았다
시나리오 개요: 좁은 화면에서도 주문 버튼이 가려지지 않는다
먼저 장바구니에 상품이 5개 담겨 있다
만일 화면 폭을 "<뷰포트>" 로 바꾸면
그러면 "주문하기" 버튼을 누를 수 있다
그리고 화면 "cart-filled" 를 읽으면 "가격이 잘리거나 겹쳐 보이지 않는다"
예:
| 뷰포트 |
| desktop |
| mobile |
브라우저 스텝
test/features/L2-ui-checkout/cart.steps.tsWhen('{string} 버튼을 누르면', async function (this: AppWorld, name: string) {
// 역할과 이름으로만 찾는다. CSS 클래스·컴포넌트 이름은 프레임워크를 바꾸면 사라진다.
await this.page.byRole('button', { name }).click();
});
Then('{string} 문구가 보인다', async function (this: AppWorld, text: string) {
await expect(this.page.byText(text)).toBeVisible({ timeoutMs: 5_000 });
});
Then('{string} 버튼은 비활성 상태다', async function (this: AppWorld, name: string) {
await expect(this.page.byRole('button', { name })).toBeDisabled();
});
// ── 2단계: 시각 회귀 (결정적, 게이트) ──────────────────────
Then('화면 {string} 는 승인된 기준 이미지와 같다',
async function (this: AppWorld, name: string) {
await this.page.waitForStable(); // 애니메이션·폰트·이미지 로드 종료까지
const shot = await this.page.capture(name); // 리포트에 자동 첨부
await expect(shot).toMatchBaseline(); // 없으면 실패 — 승인은 별도 명령으로
});
// ── 3단계: 시각 해석 (비결정적, 기본 경고) ─────────────────
Then('화면 {string} 를 읽으면 {string}',
async function (this: AppWorld, name: string, claim: string) {
const shot = await this.page.capture(name);
const v = await shot.interpret(claim); // { holds, reason, confidence }
this.report.note('visual-interpretation', { claim, ...v });
if (belay.visual.interpret.gate) expect(v.holds).toBe(true);
});
// 브라우저 부수 신호도 관찰 대상이다
Then('콘솔 오류가 발생하지 않았다', function (this: AppWorld) {
expect(this.page.consoleErrors()).toEqual([]);
});
선택자 규칙
프론트엔드에서 블랙박스를 지키는 핵심은 무엇으로 요소를 찾느냐입니다. React에서 Svelte로 옮겨도 살아남는 기준으로만 찾아야 시나리오가 정답지 역할을 계속할 수 있습니다.
| 선택 방식 | 판정 | 이유 |
|---|---|---|
byRole('button', { name }) | 권장 | 사용자가 인지하는 방식과 같고 접근성도 함께 강제됨 |
byLabel · byPlaceholder | 권장 | 폼 요소의 의미론적 식별자 |
byTestId('cart-total') | 차선 | 역할로 표현 불가할 때만. 계약이므로 포팅 시에도 유지해야 함 |
.css .class > div:nth-child(2) | 금지 | 스타일 변경만으로 깨짐. 프레임워크 교체 시 전멸 |
| 컴포넌트 인스턴스·내부 store 접근 | 금지 | 블랙박스 위반. 브라우저 밖에서는 존재하지 않는 개념 |
프론트 단독 프로젝트
src/가 프론트엔드만인 경우, 백엔드 자리를 계약 기반 mock이 대신합니다.
test/contracts/의 OpenAPI·GraphQL 스키마로 스텁을 띄우고 브라우저의 네트워크를 그쪽으로 돌리면,
서버 없이도 로딩·에러·빈 상태·지연 같은 화면 분기를 전부 재현할 수 있습니다.
// 화면 분기를 만들기 위해 백엔드 응답을 조작한다 — mock은 우리 것이므로 허용
Given('상품 목록 조회가 {int}ms 지연된다', async function (this: AppWorld, ms: number) {
await this.mock.api.onGet('/api/v1/products').delay(ms);
});
Given('상품 목록 조회가 실패한다', async function (this: AppWorld) {
await this.mock.api.onGet('/api/v1/products').reply(503);
});
// → "그러면 재시도 버튼이 보인다" 같은 화면 계약을 결정적으로 검증할 수 있다
12 테스트 환경 제어
바깥에서 만들 수 없는 상황을, 내부를 뚫지 않고 만들어 냅니다.
정말 확인하고 싶은 것들은 대개 평범한 요청으로는 재현되지 않습니다. 30일 뒤에 만료되는 쿠폰, 결제사가 502를 뱉는 순간, DB가 잠깐 끊겼을 때의 재시도. 여기서 흔한 유혹이 "테스트에서 DB를 직접 건드리자"인데, 그 순간 포팅 가능성이 사라집니다.
Belay는 대신 제어 표면을 씁니다. 조작 대상은 두 종류이고, 둘의 규칙이 다릅니다.
Mock Infra — 우리 것
외부 API 스텁, 브로커, 테스트용 DB는 Belay가 띄운 것입니다. 응답을 바꾸든 죽이든 자유롭게 조작하고, 수신한 호출을 검증해도 됩니다.
SUT — 남의 것
시계 이동이나 시드 주입이 필요하면 control에 선언된 테스트 전용 계약을 통해서만. 컨테이너 exec이나 내부 상태 조작은 금지입니다.
| 제어 | 호출 | 검증하려는 것 |
|---|---|---|
| 시계 | this.control.clock.advance('P30D') | 만료, 스케줄, 유예 기간, 정산 마감 |
| 시드 | this.control.seed('catalog.yaml') | 대량 데이터 전제 조건, 페이지네이션 |
| 스텁 응답 | this.mock.pg.reply(502) | 외부 장애 시 폴백·재시도·사용자 안내 |
| 스텁 지연 | this.mock.pg.delay(5_000) | 타임아웃 처리, 로딩 상태, 서킷 브레이커 |
| 수신 검증 | this.mock.pg.calls() | 멱등성, 재시도 횟수, 전송 페이로드 |
| 장애 주입 | this.control.faults.kill('postgres') | 연결 복구, 헬스체크, 데이터 정합 |
| 네트워크 | this.control.faults.latency('postgres', 800) | 느린 의존성에서의 성능·타임아웃 경계 |
# language: ko
기능: 결제사 장애 대응
시나리오: 결제사가 일시 장애면 주문을 보류하고 한 번만 재청구한다
먼저 장바구니에 상품이 담겨 있다
그리고 결제사가 처음 2번의 요청에 502 로 응답한다
만일 주문을 확정하면
그러면 응답은 성공이다
그리고 30초 안에 주문 상태 "PENDING_PAYMENT" 알림을 받는다
그리고 결제사에 도달한 청구 요청은 동일한 멱등키를 갖는다
시나리오: 미결제 주문은 3일 뒤 자동 취소된다
먼저 "PENDING_PAYMENT" 상태의 주문이 있다
만일 시간이 3일 흐르면
그러면 주문 상태는 "CANCELLED" 이다
그리고 재고가 원래대로 복구된다
Given('결제사가 처음 {int}번의 요청에 {int} 로 응답한다',
async function (this: AppWorld, times: number, status: number) {
await this.mock.pg.onPost('/charges').replyTimes(times, status).thenPassthrough();
});
When('시간이 {int}일 흐르면', async function (this: AppWorld, days: number) {
await this.control.clock.advance(`P${days}D`);
await this.control.drainScheduledJobs(); // 배치가 돌 때까지 조건 대기
});
Then('결제사에 도달한 청구 요청은 동일한 멱등키를 갖는다', async function (this: AppWorld) {
const keys = (await this.mock.pg.calls('POST /charges'))
.map((c) => c.headers['idempotency-key']);
expect(new Set(keys).size).toBe(1); // 몇 번 재시도했든 중복 청구는 없어야 한다
});
/__test/clock을 쓰기로 했다면 Go로 재작성한 구현도
같은 창구를 제공해야 합니다. 부담스러워 보이지만, 이건 비용이 아니라 이득입니다 —
"시간을 앞으로 돌릴 수 있는가"는 어차피 그 시스템이 테스트 가능한지를 가르는 성질이고,
선언해 두면 포팅 대상도 그 성질을 잃지 않습니다.
13 블랙박스 경계
무엇을 만져도 되고 무엇을 만지면 안 되는지에 대한 명시적 선.
| 행위 | 판정 | 이유 |
|---|---|---|
| HTTP · GraphQL · gRPC · WS 호출 | 허용 | 공개 계약. 재작성해도 유지되어야 하는 것 |
| CLI 실행 및 종료 코드·stdout 검증 | 허용 | 사용자가 실제로 쓰는 창구 |
| 발행된 이벤트·메시지 관찰 | 허용 | 외부에 드러나는 부수효과 |
| 브라우저 조작 · 역할/라벨 기반 요소 선택 | 허용 | 사용자가 화면을 쓰는 방식 그대로 |
| 스크린샷 캡처 · 승인된 baseline 비교 | 허용 | 임계치가 선언되어 있어 결정적 |
| Mock 인프라 응답 조작 · 수신 호출 검증 | 허용 | mock은 Belay가 띄운 것. SUT의 관찰 가능한 행동을 본다 |
선언된 제어 표면(/__test/*) 사용 | 허용 | 계약의 일부. 포팅 대상도 동일하게 구현해야 함 |
| 내부 스키마 기준 DB 직접 조회/조작 | 금지 | 구조 변경마다 깨짐. 포팅 시 아예 성립 불가 |
src/ 코드 임포트·내부 함수 호출 | 금지 | 언어를 바꾸는 순간 무효 |
| CSS 클래스 체인·컴포넌트 인스턴스로 요소 선택 | 금지 | 스타일 변경만으로 깨지고, 프레임워크 교체 시 전멸 |
SUT 컨테이너 exec·내부 시계 직접 변경 | 금지 | 제어가 필요하면 control에 창구로 선언할 것 |
| 로그 문자열 매칭으로 단언 | 금지 | 로그는 계약이 아님. 관찰이 필요하면 인터페이스로 승격 |
| 시각 해석(VLM) 결과를 단독 게이트로 | 지양 | 비결정적 판정은 신뢰 가능한 결과라는 전제를 무너뜨림 |
고정 sleep으로 타이밍 맞추기 | 지양 | 플레이키의 주범. 조건 기반 waitFor를 쓸 것 |
| 시나리오 간 상태 공유 (쿠키·세션 포함) | 지양 | 순서 의존은 결과의 신뢰를 무너뜨림 |
한 가지 예외적 회색지대는 테스트 전용 인터페이스입니다. 시간 이동이나 시드 데이터 주입처럼
바깥에서 도저히 만들 수 없는 상태가 있다면, 내부를 뚫는 대신 /__test/clock 같은
명시적이고 문서화된 창구를 SUT가 제공하게 하십시오. 포팅 대상도 같은 창구를 구현해야 하므로 계약의 일부로 남습니다.
14 시나리오 계층
디렉터리가 곧 계층이고, 계층이 곧 실행 순서이자 실패 해석의 문법입니다.
계층이 나뉘어 있으면 실패가 곧 진단이 됩니다. L1은 다 통과하는데 L2가 무너졌다면 개별 기능이 아니라
상태 전이나 연결이 문제라는 뜻이고, 에이전트에게 넘길 때도 "어디를 보라"는 정보가 함께 갑니다.
빠른 피드백이 필요하면 belay run --upto L1처럼 계층을 잘라 실행합니다.
15 포팅과 재작성
Belay가 존재하는 진짜 이유.
시나리오가 src/ 바깥에 있고 공개 인터페이스로만 말한다면, src/는 교체 가능한 부품이 됩니다.
Express로 시작한 프로토타입을 Kotlin/Spring으로 다시 쓰든, REST를 GraphQL로 바꾸든,
React 화면을 Svelte로 옮기든, 절차는 같습니다.
- 기준선 확보 — 현재
src/로 전 계층 통과를 확인하고 리포트를 저장합니다. 통과하지 않는 스펙은 기준이 될 수 없습니다. - 대상 교체 — 새 구현을
src/에 놓고belay.config.ts의build/start만 새 스택에 맞게 바꿉니다.surfaces와test/는 한 글자도 손대지 않습니다. - 계층 순 점등 — L0부터 통과시켜 올라갑니다. 각 계층 통과가 곧 진척도이고, 남은 실패 목록이 곧 남은 할 일 목록입니다.
- 완료 판정 — 전 계층 통과 = 포팅 완료. "다 옮긴 것 같다"가 아니라 확인된 사실입니다.
test/를 고쳐야만 통과한다면, 그건 대개 두 가지 중 하나입니다.
시나리오가 원래 구현에 몰래 결합돼 있었거나(고쳐야 할 것은 시나리오), 정말로 계약이 바뀐 것(고쳐야 할 것은 스펙과 합의).
어느 쪽이든 조용히 통과시키려고 시나리오를 낮추는 일만은 하지 마십시오 — 로프를 느슨하게 풀어 두는 것과 같습니다.
표면을 바꿀 때
| 교체 유형 | 무엇이 바뀌고 무엇이 남는가 |
|---|---|
| 언어·프레임워크 교체 Express → Spring |
build/start만 수정. surfaces·test/는 그대로. 가장 순수한 형태의 포팅. |
| 프로토콜 교체 REST → GraphQL |
계약 자체가 바뀌므로 드라이버 호출부(스텝 구현)는 수정됩니다. 다만 .feature 문장은 그대로여야 합니다 — 시나리오가 바뀐다면 그건 포팅이 아니라 기능 변경입니다. |
| 프론트 프레임워크 교체 React → Svelte |
역할·라벨 기반 선택자와 data-testid가 유지되면 스텝도 그대로입니다. 여기서 선택자를 고쳐야 한다면 원래 시나리오가 프레임워크에 결합돼 있었다는 뜻입니다. |
| 디자인 변경 동반 | 구조 단언은 유지되지만 시각 baseline은 무효화됩니다. baseline 갱신은 리뷰를 거친 명시적 승인으로 처리하세요 — 자동 갱신은 안전망을 스스로 지우는 일입니다. |
에이전트에게 맡길 때도 같은 구조가 그대로 작동합니다. test/는 읽기 전용으로 주고
src/만 수정 권한을 준 뒤 belay run을 반복하게 하면, 채점 기준을 스스로 고칠 수 없는
상태에서 구현만 수렴시키게 됩니다.
test/
읽기 전용 조건으로 Rust 재작성을 맡겼습니다. 결과는 수렴 — 약 14회의 실행, 25분 만에 50/50,
문서에 없던 동작 세 가지도 실패 목록이 요구했기 때문에 정확히 복제되었고, 채점 기준은 손대지 않았습니다.
측정된 단서 하나: 작은 모델은 실패를 남긴 채 일찍 멈춥니다. 실패 목록이 주는 것은 방향이고,
루프를 계속 돌리는 일은 로프를 쥔 쪽의 몫입니다.
16 결과와 신뢰
통과했다는 사실보다, 통과를 믿을 수 있다는 사실이 중요합니다.
실행마다 남는 증거
스텝 타임라인, 프로토콜 전문, 스크린샷·시각 diff·비디오, SUT/인프라 로그, 실패 시점 DOM과 콘솔. 실패를 재현하려고 다시 돌릴 필요가 없어야 합니다.
비결정적 판정은 분리 표기
시각 해석 결과는 통과/실패와 섞이지 않고 경고로 따로 집계됩니다. 모델 응답 원문과 근거 이미지가 함께 남아 사람이 판단할 수 있습니다.
플레이키는 실패로 취급
재시도로 초록불을 만들지 않습니다. 불안정한 시나리오는 격리 표시 후 원인을 고칩니다 — 흔들리는 안전망은 없느니만 못합니다.
미구현 스텝은 침묵하지 않는다
정의되지 않은 스텝, 스킵된 시나리오는 결과에 명시되고 커버리지에서 차감됩니다. 조용한 통과가 가장 위험합니다.
기계가 읽는 출력
junit.xml과 json은 CI 게이트와 에이전트 루프에 그대로 물립니다. 종료 코드만으로 판단이 서야 합니다.
$ belay run
▸ infra postgres ✓ 1.8s kafka ✓ 4.1s payment-gateway ✓ 0.6s
▸ build belay-sut ✓ 24.1s
▸ ready http://localhost:8080/health ✓ 3.2s
▸ surfaces http · graphql · grpc · ws · browser(chromium) ✓
L0-smoke 2 시나리오 2 통과
L1-account 14 시나리오 14 통과
L1-catalog 6 시나리오 6 통과 graphql
L2-order 9 시나리오 8 통과 1 실패 grpc · ws
L2-ui-checkout 7 시나리오 7 통과 browser · ⚠ 1 시각 경고
L3-cross 4 시나리오 4 통과
✗ L2-order/checkout.feature:31 재고가 부족하면 주문을 거절한다
그러면 gRPC 상태는 "FAILED_PRECONDITION" 이다
expected FAILED_PRECONDITION, received INTERNAL
→ .belay/reports/2026-07-29T20-14/L2-order-31/
⚠ L2-ui-checkout/cart.feature:24 [시각 해석 · 게이트 아님]
주장: "가격이 잘리거나 겹쳐 보이지 않는다" — 성립하지 않음 (confidence 0.71)
"mobile 뷰포트에서 합계 금액 끝자리가 컨테이너 밖으로 넘침"
→ cart-filled.mobile.png · 확인 후 구조 단언이나 baseline으로 승격 권장
▸ teardown keepOnFailure=true — 컨테이너 유지 (belay down 으로 정리)
41/42 통과 · 경고 1 · 소요 2m 38s · exit 1
17 이 문서의 주장은 어디서 검증되는가
설계 문서가 코드보다 앞서 나가면 첫 사용자가 배신당합니다. 각 주장이 어느 예제에서 실제로 돌아가는지 적어 둡니다.
examples/ 는 전시장이 아니라 회귀 스위트입니다.
npm run examples 가 사용자와 똑같은 방식으로 전부 실행하고,
CI 는 여기서 실패하면 빌드를 깨뜨립니다.
| 예제 | 언어 | 여기서 증명되는 것 |
|---|---|---|
01-http-todo | 영어 | Docker 없는 인메모리 stub · 실제 SMTP 캡처 · 장애 주입(500 · 지연 · 단절) · 시계 제어 · 비동기 부수효과 대기 |
02-porting | 영어 | 같은 시나리오가 Node 와 Python 두 구현을 모두 통과합니다. 이 문서 전체의 핵심 주장 |
03-graphql-ws | 영어 | 한 시나리오에서 GraphQL 과 WebSocket 을 엮기 · waitFor/quietFor · 부분 성공을 성공이라 부르지 않기 |
04-grpc | 영어 | unary 호출 · 전송 계층 코드와 도메인 코드 분리 · 멱등 재시도 · 서버 스트리밍 |
05-browser | 영어 | 의미 기반 선택자 · 승인된 baseline 비교 · 뷰포트별 반복 · 콘솔 오류 관찰 |
아직 구현되지 않은 것
동작하지 않는 것, 그리고 의도적으로 지원하지 않는 것을 발견되게 두는 대신 여기에 명시합니다.
- 큐(Kafka) 드라이버 — 1.0 에서 구현되었습니다 (
surfaces.queue, 선택적 peerkafkajs): key·헤더를 실은 발행, latest 오프셋 구독과 WebSocket 과 동일한waitFor/quietFor/mark재생 계약, admin API 를 통한 토픽 자동 생성. 실제 단일 노드 Kafka(apache/kafka 3.8, KRaft)로 현장 검증했습니다 — 발행→소비 왕복, key/헤더 보존, latest 오프셋 의미론, 침묵 단언. 버퍼링 의미론은 선언된 클라이언트 경계에 대한 단위 테스트로 고정되어 있습니다. Docker 인프라 경로와 마찬가지로 상시 검증은 아닙니다: 예제는 의도적으로 Docker 없이 돌기 때문에, 매 실행 실제 브로커를 두드리는 예제는 없습니다. - Docker 인프라 경로 —
image:로 컨테이너를 띄우는 코드는 있고 현장에서 한 차례 검증되었습니다(RealWorld showcase의 Postgres 컨테이너, macOS/colima). 다만 예제가 전부 Docker 없이 도는 방식이라 상시 검증되고 있지는 않습니다.command:·stub:·mail:경로를 우선 쓰세요. 1.0 부터ports: ['15432:5432']처럼 호스트≠컨테이너 매핑을 선언할 수 있고, 준비 판정 명령이 생성된 컨테이너 이름을 참조할 수 있습니다 —ready: { command: 'docker exec {container} pg_isready' }. VM 포트포워더 뒤에서는 TCP 가 서비스보다 먼저 열리므로 이것이 정직한 프로브입니다.
Windows
네이티브로 지원합니다 (이전에는 거부했습니다). 정리(teardown)는 POSIX 가 프로세스 그룹을 죽이는 자리에서 taskkill /T 로 프로세스 트리 전체를 죽이고, belay down 은 선언된 포트를 아직 쥐고 있는 프로세스까지 추적해 정리합니다. 플랫폼 특성 두 가지: 종료 시그널은 POSIX 개념이라 Windows 에서는 모든 종료가 강제 종료입니다 — 종료 전 정리 시간이 필요한 앱이라면 그 시간을 얻지 못합니다. 그리고 --link symlink 는 심볼릭 링크에 개발자 모드가 필요한 환경에서 디렉터리 정션(junction)으로 대체됩니다. WSL2 도 계속 완전히 지원됩니다.
18 용어집
문서 전반에서 쓰는 말들.
| 용어 | 뜻 |
|---|---|
| Belay | 등반에서 추락하는 동료를 로프로 잡아 주는 확보 행위. 이 프레임워크의 이름이자 역할. |
| SUT | System Under Test. src/에 놓인 검증 대상. Belay는 이것의 내부를 알지 못한다. |
| Surface | 테스트가 SUT를 두드릴 수 있는 공개 창구. HTTP, CLI, 큐 등. 설정에 선언된 것만 존재한다. |
| Feature | Gherkin으로 쓰인 실행 가능한 명세 파일. 사람이 읽는 스펙이자 기계가 돌리는 검증. |
| Step Definition | Gherkin 문장을 드라이버 호출로 번역하는 TypeScript 구현. 얇게 유지한다. |
| Driver | 하나의 Surface를 감싼 어댑터. 도메인 로직을 담지 않는다. |
| Exchange | 드라이버가 반환하는 정규화된 교환 결과. 프로토콜이 달라도 같은 모양이므로 공통 스텝을 쓸 수 있다. |
| Semantic Locator | 역할·라벨·접근 가능한 이름으로 화면 요소를 찾는 방식. 프레임워크가 바뀌어도 살아남는다. |
| Visual Baseline | 승인된 기준 스크린샷. 커밋되며, 갱신은 리뷰를 거친 명시적 승인으로만 이루어진다. |
| Visual Interpretation | 캡처 이미지를 모델이 읽어 내는 판단. 비결정적이므로 기본은 게이트가 아닌 경고. |
| Control Plane | 시계·시드·장애 주입처럼 바깥에서 만들 수 없는 상태를 만드는, 선언된 제어 표면. |
| Mock Infra | SUT가 의존하는 주변 환경의 테스트용 대역. DB, 브로커, 외부 API 스텁. Belay가 띄운 것이므로 자유롭게 조작 가능. |
| Layer | 시나리오 계층(L0~L3). 실행 순서이자 실패를 해석하는 문법. |
| Baseline | 포팅 전 현재 구현으로 확보해 둔 전 계층 통과 기록. 재작성의 기준선. |