Belay — 코드는 에이전트가 씁니다. 당신은 열 문장을 검수합니다 Belay — 코드는 에이전트가 씁니다. 당신은 열 문장을 검수합니다

코드는 에이전트가 씁니다. 당신은 열 문장을 검수합니다.

LLM이 코드를 얼마나 빠르게 써내든, 그 코드가 지금도 스펙대로 동작하는지는 별개의 문제입니다. Belay는 바이브 코딩을 위한 블랙박스 시나리오 하네스입니다. 검증 대상 프로젝트를 src/에 통째로 두고, 바깥에서 오직 공개 인터페이스만으로 두드려 보는 실행 가능한 스펙을 test/에 쌓습니다. 브라우저의 화면이든 gRPC 스트림이든, 사용자가 닿는 모든 표면이 대상입니다. 텍스트로만 존재하던 스펙이 매번 실제로 돌아가는 계약이 됩니다. 그리고 그 계약은 코드가 아니라 자연어 문장으로 쓰이므로, 한 줄씩 읽고 검수해야 하는 대상은 diff가 아니라 스펙이 됩니다.

Gherkin .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 실행에서 검증됩니다.

에이전트에게 넘기기

initAGENTS.md에 넣는 규칙은 짧습니다. 핵심은 하나 — 채점자와 응시자를 분리하는 것입니다.

AGENTS.md
## Belay 하네스 규칙

- 구현은 `src/` 안에서만 수정합니다.
- `test/` 는 읽기 전용입니다. 시나리오를 고쳐서 통과시키지 마세요.
- 작업을 마쳤다고 보고하기 전에 `belay run` 을 실행하고 출력을 그대로 첨부하세요.
- 스펙이 잘못되었다고 판단되면 고치지 말고, 근거와 함께 보고만 하세요.

여기까지 오면 반복 구간은 세 줄로 줄어듭니다. 에이전트가 src/를 고치고, belay run이 판정하고, 실패 목록이 다음 할 일이 됩니다. 채점 기준을 스스로 고칠 수 없으므로 구현만 수렴합니다 — 이것이 Belay가 말하는 안전한 바이브 코딩입니다.

터미널
belay run --only L0        # 앵커 확인
belay new L1-account/signup # 스펙을 시나리오로 먼저 고정
belay run                   # 실패 목록 = 남은 할 일

02 이미 있는 프로젝트 채택하기

안전망이 가장 절실한 프로젝트는 이미 굴러가고 있는 프로젝트입니다.

Belay는 빈 디렉터리에서만 시작할 수 있는 것이 아닙니다. 이미 돌고 있는 서비스를 가리키면, 코드를 읽어서 원래는 처음부터 설명해야 했을 부분을 채워 넣습니다.

terminal
belay 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@v4submodules: true가 필요합니다.

이미 있는 코드를 시나리오로 만들기

belay analyze는 연결된 프로젝트를 읽어 .belay/analysis.md에 체크리스트를 씁니다. 찾아낸 엔드포인트를 L1- 디렉터리가 될 도메인 단위로 묶고, 각각에 성공 경로 후보와 거부 경로 후보를 하나씩 달아 둡니다.

.belay/analysis.md
### `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

재현 가능하지 않고 기록도 남지 않습니다. 커밋마다 돌릴 수 없다면 안전망이라 부를 수 없습니다.

Belay

실행되는 스펙, 구현과 분리된 위치

시나리오는 src/ 바깥에 삽니다. 안쪽을 통째로 갈아엎어도 시나리오는 그대로 남아 정답지 역할을 합니다.

병목의 나머지 절반: 코드를 읽는 일

바이브 코딩은 코드를 쓰는 비용만 낮췄을 뿐, 그 코드를 믿는 비용은 오히려 올려놨습니다. 에이전트는 500줄짜리 diff를 1분 만에 내놓고, 그것을 눈으로 승인하려면 500줄을 전부 읽어야 합니다. 다음 커밋에서도, 그다음 커밋에서도 마찬가지입니다. 검수해야 할 산출물이 우리를 빠르게 만들어 준 바로 그 속도로 함께 늘어납니다.

Belay는 대신 훨씬 작고, 이미 읽을 줄 아는 언어로 쓰인 산출물을 건넵니다.

test/features/L2-cart/coupon.feature
  시나리오: 이미 사용한 쿠폰은 다시 쓸 수 없다
    먼저 쿠폰 "WELCOME" 은 이미 사용되었다
    만일 그 쿠폰을 장바구니에 적용하면
    그러면 요청은 거절된다
    그리고 장바구니 결제 금액은 그대로다

검수 대상은 이것입니다. 프레임워크도, 클래스 이름도, 제어 흐름도 없고 — 우리가 합의하려는 동작만 남습니다. 문장 열 줄이 구현 천 줄이 해도 되는 일의 범위를 못 박고, 애초에 그 기능을 요구한 사람도 그대로 읽을 수 있습니다.

게다가 이 문장들은 구현에 대한 설명이 아니라 구현에 대한 검사입니다. 스텝은 사용자가 닿을 수 있는 표면만 두드리므로, 스위트가 초록색이라는 건 그 문장들이 실제로 돌아가는 시스템에서 참이라는 뜻입니다. 무언가를 목으로 발라 맞춰 놓은 결과가 아닙니다. 스펙을 검수하고, 구현 검수는 스위트에 맡기십시오.

AS-IS는 사람이 매 커밋마다 diff 전체를 읽고, TO-BE는 사람이 문장 열 줄을 한 번 읽고 diff는 스위트가 읽는다 왼쪽: 에이전트의 500줄 diff가 사람 앞에 놓이고, 사람이 전부 읽는다. 다음 커밋에서 또 읽는다. 오른쪽: 사람은 한 번 적힌 열 줄짜리 시나리오를 읽고, belay run이 매 커밋마다 구현을 읽어 50/50으로 답한다. AS-IS — 구현을 검수한다 src/ diff · +512 −348 ⋯ 이후 500여 줄 사람 전부 읽는다 — 다음 커밋에서 또 한 번 TO-BE — 스펙을 검수한다 시나리오: 사용한 쿠폰은 거절된다 먼저 쿠폰 "WELCOME" 은 이미 사용되었다 만일 그 쿠폰을 장바구니에 적용하면 그러면 요청은 거절된다 그리고 결제 금액은 그대로다 사람 문장 열 줄만 읽는다 — 한 번 적으면 계속 유효 src/ diff +512 −348 belay run 50/50 구현은 스위트가 읽는다 — 매 커밋마다
양쪽 모두 매 커밋마다 같은 diff를 받습니다. 달라지는 건 그것을 읽는 쪽입니다: 500줄은 스위트가 가져가고, 사람 몫으로 남는 것은 문장 열 줄 — 애초에 읽을 가치가 있던 바로 그 문장들입니다.
통과가 보장하는 범위는 적어 둔 것 전부이고, 적지 않은 것은 아무것도 아닙니다. 빠진 동작이 있다는 사실은 Belay가 알려주지 못합니다 — 그 문장은 사람이 써야 합니다. 대신 한 번 적힌 문장은 모든 커밋에서, 그리고 어떤 구현으로 갈아끼워도 계속 지켜집니다.
목표는 자신감입니다. "다 갈아엎고 Go로 다시 써도 될까?"라는 질문에 "시나리오 전부 통과하면 된 거야"라고 답할 수 있는 상태 — Belay가 만들려는 건 그 상태입니다.

04 핵심 개념

등반자는 자유롭게 오르고, 확보자는 로프를 잡습니다.

src/는 오르고, test/는 확보하고, belay.config.ts가 앵커가 된다 왼쪽의 src/는 등반자이며 언어·프레임워크·아키텍처를 자유롭게 바꿀 수 있다. 오른쪽의 test/는 확보자이며 선언된 표면으로만 src/에 접근한다. belay.config.ts는 로프가 통과하는 앵커로 두 쪽 위에 놓인다. 앵커 belay.config.ts 등반자 src/ 언어·프레임워크 모두 자유 언제든 통째로 교체 가능 확보자 test/ 실행되는 시나리오 src/를 임포트하지 않는다 두드리는 곳은 선언된 표면뿐
로프는 확보자에서 앵커를 거쳐 등반자까지 이어집니다. 그래서 앵커는 관례가 아니라 파일이어야 합니다 — 앵커를 옮기면 양쪽이 함께 움직이고, 앵커가 없으면 로프는 아무것도 잡지 못합니다.

등반자 = src/

에이전트가 마음껏 오르는 곳. 언어도 프레임워크도 아키텍처도 자유롭게 바뀔 수 있습니다.

확보자 = test/

바깥에서 로프를 잡습니다. 안쪽 구조를 모르고, 알 필요도 없습니다. 떨어지는 순간만 잡아냅니다.

앵커 = 루트 실행 정의

무엇을 어떻게 띄우고 어떻게 두드릴지 고정하는 지점. 이게 박혀 있어야 로프가 의미를 갖습니다.

여기서 따라 나오는 세 가지 원칙이 Belay의 전부입니다.

  1. 블랙박스 — 테스트는 배포된 산출물이 노출하는 인터페이스로만 대상을 다룹니다. 소스 임포트, 내부 함수 호출, 내부 DB 직접 조작 모두 금지.
  2. 재현 가능 — 인프라 기동부터 정리까지 한 명령에 담깁니다. 내 노트북에서 되는 것과 CI에서 되는 것이 같아야 합니다.
  3. 스펙이 곧 테스트.feature는 사람이 읽는 명세인 동시에 기계가 돌리는 검증입니다. 둘이 갈라질 여지를 없앱니다.

05 프로젝트 구조

최상단은 언제나 srctest 둘뿐입니다.

# 프로젝트 루트
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/에 대해 아는 유일한 것은 이 파일입니다. 어떻게 빌드하고, 무엇에 의존하고, 언제 준비된 것으로 볼지, 그리고 어떤 창구로 두드릴지. 여기 적히지 않은 내부 사정은 테스트가 알 수 없습니다 — 그게 의도입니다.

belay.config.ts
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 run 한 번의 아홉 단계를 등반 루트로 그린 그림 1단계부터 4단계까지 올라간다: 사전 점검, 인프라 기동, 빌드와 기동, 준비 완료 판정. 5단계부터 7단계는 확보된 상태로 평지를 지난다: 시나리오 실행, 데드라인 감시, 증거 수집. 8단계와 9단계는 내려온다: 정리와 종료 코드 확정. 올라간다 확보 상태로 실행 반드시 내려온다 01 02 03 04 05 06 07 08 09 사전 점검 인프라 기동 빌드·기동 준비 완료 시나리오 데드라인 증거 수집 정리 종료 코드
각 단계는 다음 단계의 관문입니다. 땅을 확인하기 전에 빌드하지 않고, 준비 완료가 증명되기 전에 시나리오를 실행하지 않습니다. 내려오는 길만은 조건부가 아닙니다 — 올라가다 무엇이 깨져도 08과 09는 실행됩니다.
01
설정 해석 & 사전 점검 belay.config.ts를 읽고 포트 충돌, 필수 도구, 남은 이전 실행 잔재를 확인합니다. 여기서 실패하면 아무것도 띄우지 않습니다.
02
Mock 인프라 기동 DB, 브로커, 외부 API 스텁을 의존 순서대로 올리고 각자의 ready 조건을 만족할 때까지 대기합니다.
03
SUT 빌드 & 기동 app.buildapp.start. 주입되는 환경변수는 방금 띄운 인프라를 가리킵니다. src는 자기가 테스트 중인지 알지 못합니다.
04
준비 완료 판정 app.ready가 통과해야 다음으로 넘어갑니다. 타임아웃 시 부팅 로그를 그대로 붙여 실패 처리 — "그냥 느렸던 것"과 "안 뜬 것"을 구분합니다.
05
시나리오 실행 계층 순서(L0 → L3)로 .feature를 실행합니다. 각 시나리오마다 상태 초기화·mock 스텁 리셋·새 브라우저 컨텍스트가 준비되고, 모든 프로토콜 교환이 트레이스에 기록됩니다.
06
데드라인 감시 스텝 · 훅 · 시나리오가 각각 제한 시간을 갖습니다(기본 30s · 30s · 120s). 끝나지 않는 스텝은 해당 스텝 이름과 함께 실패 처리되고 실행은 계속됩니다 — 멈춘 스텝 하나가 CI 잡 전체를 인질로 잡는 일이 없어야 합니다.
07
증거 수집 스텝별 타임라인, 프로토콜 트랜스크립트(HTTP·GraphQL·gRPC·WS), 스크린샷과 시각 diff, 실패 시점의 DOM·콘솔·비디오, SUT·인프라 로그를 .belay/reports에 남깁니다.
08
Teardown 기동의 역순으로 정리합니다. keepOnFailure가 켜져 있으면 실패한 실행의 컨테이너는 살려 두어 사후 조사에 씁니다.
09
종료 코드 확정 하나라도 실패하면 non-zero. 애매한 통과는 없습니다 — 스킵과 미구현 스텝도 결과에 명시됩니다.
중간에 어느 단계가 깨지든 Belay는 그 시점까지 올린 것을 역순으로 반드시 내립니다. 고아 컨테이너와 붙잡힌 포트는 다음 실행을 오염시키고, 오염된 실행 결과는 신뢰를 잃습니다.

08 시나리오 작성

Gherkin으로 씁니다. 사람이 읽는 명세와 기계가 돌리는 검증이 같은 문장이고, 그래서 src/를 한 번도 열지 않는 사람도 스펙을 검수할 수 있습니다.

test/features/L1-account/signup.feature
# 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.ts
import { 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 단언 같은 공통 스텝을 프로토콜을 넘나들며 재사용할 수 있습니다.

여섯 프로토콜이 하나의 Exchange로 정규화되고, 공통 스텝이 그것을 검증한다 http, graphql, grpc, grpc stream, ws, cli가 모두 ok·status·body·errors·meta·elapsedMs를 담은 하나의 Exchange 형태로 모인다. 이후에는 프로토콜과 무관하게 한 벌의 공통 스텝이 그 형태를 검증한다. http graphql grpc grpc stream ws cli 정규화 Exchange ok · status · body errors[] · meta · elapsedMs 한 번만 작성 공통 스텝 응답은 성공이다 오류 코드는 … 이다
가운데가 좁아지는 것이 핵심입니다. 프로토콜을 추가할 때 늘어나는 것은 드라이버 하나이고, 어휘는 늘어나지 않습니다 — 그래서 표면이 바뀌어도 시나리오 문장은 그대로 참입니다.
프로토콜드라이버 호출시나리오가 관찰하는 것
HTTPthis.http.post(path, body)상태코드, 헤더, 본문, 응답 시간
GraphQLthis.gql.query(doc, vars)data 형태, errors[].extensions.code, 부분 성공
gRPCthis.grpc.call('svc/Method', msg)status code, trailer, 응답 메시지
gRPC 스트림this.grpc.stream(...)메시지 순서, 종료 상태, 백프레셔 동작
WebSocketthis.ws.connect()waitFor()수신 메시지, 순서, 재연결, close code
Queuethis.queue.publish(topic, msg) · consume(topic)waitFor()발행된 메시지, key 와 헤더, 순서, 오지 않아야 하는 것 (quietFor)
Queuethis.queue.consume(topic)발행된 이벤트, 키, 중복/순서 보장
CLIthis.cli.run(args)종료 코드, stdout/stderr

정규화된 교환 결과

test/support/drivers/types.ts
export 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.ts
Then('응답은 성공이다', 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" 이다
구현 현황 (1.0.0). HTTP · GraphQL · WebSocket · CLI · gRPC 드라이버와 Exchange 정규화는 구현되어 예제에서 매 실행 검증됩니다 (gRPC 는 examples/04-grpc — unary · 오류 코드 · 서버 스트리밍). gRPC 표면은 @grpc/grpc-js@grpc/proto-loader 를, queue 표면은 kafkajs 를 선택적 peer 의존성으로 필요로 합니다. queue 드라이버는 admin API 로 없는 토픽을 만들어 주므로 브로커의 auto-create 설정에 의존하지 않습니다. 검증 상태는 17장에 정직하게 적혀 있습니다 — 실제 브로커로 현장 검증, 예제에 의한 상시 검증은 아님.
스트리밍에는 시간 축이 있습니다. WebSocket·gRPC 스트림·큐 소비는 "언젠가 온다"가 아니라 조건과 시한으로 단언해야 합니다. waitFor로 기다리고, "오지 않아야 한다"는 quietFor로 명시하세요. sleep(3000)은 느린 CI에서 깨지고 빠른 머신에서는 시간을 버립니다.

11 브라우저와 시각 검증

프론트엔드의 공개 인터페이스는 화면입니다. 그래서 화면을 열고, 누르고, 찍고, 읽습니다.

프론트엔드 프로젝트에서 "블랙박스"는 곧 브라우저입니다. Belay는 실제 엔진을 띄워 사용자가 하는 그대로 조작하고, 결과를 세 가지 층위로 검증합니다. 층위마다 결정성이 다르고, 게이트로 삼을 수 있는 범위도 다릅니다.

게이트

1 · 구조 단언

접근성 트리 기준으로 역할·이름·상태를 확인합니다. 완전히 결정적이므로 통과/실패의 1차 근거는 항상 여기입니다.

게이트

2 · 시각 회귀

승인된 baseline 스크린샷과 perceptual diff. 임계치와 무시 영역이 선언되어 있어 재현 가능하고, 변경은 리뷰로 승인합니다.

기본 비게이트

3 · 시각 해석

캡처한 이미지를 모델이 읽고 "범례가 겹치지 않는다" 같은 판단을 냅니다. baseline이 없는 영역을 잡아내지만 비결정적입니다.

1·2층위는 통과/실패 게이트를 지나고, 3층위는 게이트를 우회해 리포트로 간다 구조 단언과 시각 회귀는 결정적이므로 게이트에 도달해 종료 코드를 결정한다. 시각 해석은 게이트를 우회해 리포트의 경고로 남고, 사람이 확인한 뒤 1층위나 2층위로 승격시킨다. 1 · 구조 단언 역할 · 이름 · 상태 2 · 시각 회귀 승인된 baseline과 비교 3 · 시각 해석 모델이 캡처를 읽고 판단 게이트 통과 / 실패 · 종료 코드 결정적이고 재현 가능하다 빌드를 깨뜨릴 수 있는 것은 여기뿐 게이트를 우회한다 리포트 · 경고 사람이 확인한 뒤 승격시킨다 1층위 또는 2층위로
우회는 의도된 설계입니다. 대체로 맞는 판정자를 게이트에 물리면 모든 초록색이 아마도라는 뜻이 됩니다 — 그래서 3층위는 사람이 할 일만 만들 수 있고, 판정은 내리지 못합니다. 진짜 결함이라면 정상 경로는 1·2층위로의 승격이고, 그때 비로소 결정적인 게이트가 됩니다.
구현 현황 (1.0.0). 브라우저 드라이버는 구현되어 examples/05-browser 에서 검증됩니다 — 의미 기반 선택자, 캡처, baseline 비교까지. 시각 게이트가 실제로 동작하는지도 확인했습니다: 버튼 색 한 줄을 바꾸자 3.33% 차이로 실패했습니다. playwright(엔진)와 pngjs·pixelmatch(픽셀 비교)를 선택적 의존성으로 설치해야 합니다: npm i -D playwright pngjs pixelmatch && npx playwright install chromium
시각 해석은 기본적으로 실패를 만들지 않습니다. 비결정적 판정을 곧바로 게이트에 물리면 Belay가 지키려는 "결과를 믿을 수 있다"는 성질 자체가 무너집니다. 해석 결과는 리포트에 경고로 남고, 사람이 확인해 진짜 결함이면 구조 단언이나 baseline으로 승격시키는 것이 정상 경로입니다. 굳이 게이트로 쓰려면 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.ts
When('{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이나 내부 상태 조작은 금지입니다.

Mock 인프라는 자유롭게, SUT는 선언된 창구로만 Belay가 띄운 Mock 인프라는 시나리오가 제약 없이 조작할 수 있다. 검증 대상에 도달할 때는 /__test/clock, /__test/seed 같은 좁은 창구를 통해서만 가능하며, 컨테이너 exec이나 내부 상태 직접 수정은 금지된다. 시나리오 this.mock.… this.control.… 제약 없음 MOCK 인프라 — 우리 것 reply · delay · fail · kill · received() Belay가 띄운 것이므로 마음대로 조작해도 된다 선언된 창구만 /__test/clock /__test/seed SUT — 남의 것 스스로 선언한 창구로만 도달할 수 있다 컨테이너 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)느린 의존성에서의 성능·타임아웃 경계
test/features/L3-cross/payment-outage.feature
# 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 블랙박스 경계

무엇을 만져도 되고 무엇을 만지면 안 되는지에 대한 명시적 선.

test/는 선언된 표면으로만 src/에 닿고, 그 밖의 모든 경로는 막힌다 test/는 배포 산출물 바깥에 있다. 안으로 들어가는 유일한 길은 선언된 표면인 http, graphql, grpc, ws, cli, browser, 그리고 테스트 전용 제어 창구다. 경계 안쪽의 라우트, 도메인 서비스, DB 스키마, 내부 클래스는 시나리오에 보이지 않으며, 경계를 뚫고 그것에 닿는 경로는 모두 금지된다. 바깥 test/ 확보자 표면 http graphql grpc ws cli browser /__test/* src/ — 배포되는 산출물 라우트 · 핸들러 도메인 서비스 DB 스키마 내부 클래스 Belay는 이 안을 들여다보지 않는다. 이 구조는 언제든 바뀔 수 있고, 어느 것도 계약이 아니다. src/ 임포트 · DB 직접 조회 · 컨테이너 exec · 로그 문자열 매칭 허용 — 설정에 선언된 표면을 통해서만 금지 — 경계 안쪽에 닿는 모든 것
의미 있는 선은 점선 사각형 하나뿐입니다. 안쪽은 통째로 교체할 수 있고, 그 선을 넘는 것이 계약입니다. 아래 표의 모든 "금지"는 같은 규칙입니다 — 경계를 뚫는 경로가 하나라도 있으면, 통과한 시나리오는 포팅에 대해 아무것도 말해 주지 못합니다.
행위판정이유
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 시나리오 계층

디렉터리가 곧 계층이고, 계층이 곧 실행 순서이자 실패 해석의 문법입니다.

L0에서 L3까지: 실행은 아래에서 위로, 범위는 올라갈수록 넓어진다 가장 좁은 L0 Smoke가 먼저 실행되고, 이어 L1 Capability, L2 Flow, L3 Cross and Regression이 실행된다. 점선은 belay run --upto L1이 L1에서 실행을 끊는 지점을 나타낸다. L3 L2 L1 L0 실행 순서 Cross & Regression 도메인·표면을 넘나드는 시나리오, 그리고 과거 버그를 고정한 회귀 Flow 여러 호출·여러 화면이 이어지는 사용자 흐름 Capability 단일 기능 하나, 프로토콜 하나 또는 화면 하나 Smoke 떴는가, 응답하는가 --upto L1 ✂ L1은 통과, L2는 실패 ⇒ 개별 기능이 아니라 그 사이의 연결이 문제다.
계층이 순서대로 실행되므로 가장 먼저 깨진 계층이 곧 진단이 되고, 실패 목록과 함께 그 정보가 에이전트에게 전달됩니다. 계층을 잘라 실행하는 것은 위 계층이 통과한 척하지 않으면서 내부 루프를 빠르게 유지하는 방법입니다.
L0
Smoke떴는가, 응답하는가. 여기가 깨지면 나머지는 실행하지 않습니다. 원인은 거의 항상 기동 문제입니다.
L1
Capability단일 기능의 계약. 프로토콜 하나로 완결되는 요청–응답이거나, 화면 하나의 상태 계약(빈 상태·오류·로딩).
L2
Flow여러 호출·여러 화면이 이어지는 사용자 흐름. 장바구니 → 결제 → 주문 확정. 상태 전이의 정합성이 대상입니다.
L3
Cross & Regression도메인·표면을 넘나드는 시나리오(브라우저에서 주문 → WS 알림 수신 → 결제사 장애 대응)와, 과거에 터졌던 버그를 고정한 회귀 시나리오.

계층이 나뉘어 있으면 실패가 곧 진단이 됩니다. L1은 다 통과하는데 L2가 무너졌다면 개별 기능이 아니라 상태 전이나 연결이 문제라는 뜻이고, 에이전트에게 넘길 때도 "어디를 보라"는 정보가 함께 갑니다. 빠른 피드백이 필요하면 belay run --upto L1처럼 계층을 잘라 실행합니다.

15 포팅과 재작성

Belay가 존재하는 진짜 이유.

시나리오가 src/ 바깥에 있고 공개 인터페이스로만 말한다면, src/는 교체 가능한 부품이 됩니다. Express로 시작한 프로토타입을 Kotlin/Spring으로 다시 쓰든, REST를 GraphQL로 바꾸든, React 화면을 Svelte로 옮기든, 절차는 같습니다.

포팅은 src/와 설정 두 키만 바꾸고, test/는 손대지 않는다 이전에는 src/가 Express와 Node이고 전 계층이 통과한다. 이후에는 src/가 Spring과 Kotlin이며 다시 전 계층이 통과한다. 설정에서 바뀌는 것은 build와 start뿐이고 surfaces, control, suite는 그대로이며, test/는 양쪽이 동일하다. 이전 — 전 계층 통과 이후 — 전 계층 통과 src/ Express · Node 20 src/ Spring · Kotlin 21 재작성 belay.config.ts build · start → npm surfaces · control · suite — 그대로 belay.config.ts build · start → gradle surfaces · control · suite — 그대로 test/ 42개 시나리오 · 전부 통과 test/ 같은 42개 · 전부 통과 포팅 2개 키 동일
등식으로 읽으십시오. 양쪽의 초록색 열이 있어야 가운데의 교체가 "같기를 바라는 재작성"이 아니라 포팅이 됩니다. 통과시키려고 오른쪽 아래 열을 고쳐야 했다면 증명된 것은 없습니다 — 기준이 구현과 함께 움직인 것입니다.
  1. 기준선 확보 — 현재 src/로 전 계층 통과를 확인하고 리포트를 저장합니다. 통과하지 않는 스펙은 기준이 될 수 없습니다.
  2. 대상 교체 — 새 구현을 src/에 놓고 belay.config.tsbuild/start만 새 스택에 맞게 바꿉니다. surfacestest/한 글자도 손대지 않습니다.
  3. 계층 순 점등 — L0부터 통과시켜 올라갑니다. 각 계층 통과가 곧 진척도이고, 남은 실패 목록이 곧 남은 할 일 목록입니다.
  4. 완료 판정 — 전 계층 통과 = 포팅 완료. "다 옮긴 것 같다"가 아니라 확인된 사실입니다.
이 과정에서 test/를 고쳐야만 통과한다면, 그건 대개 두 가지 중 하나입니다. 시나리오가 원래 구현에 몰래 결합돼 있었거나(고쳐야 할 것은 시나리오), 정말로 계약이 바뀐 것(고쳐야 할 것은 스펙과 합의). 어느 쪽이든 조용히 통과시키려고 시나리오를 낮추는 일만은 하지 마십시오 — 로프를 느슨하게 풀어 두는 것과 같습니다.

표면을 바꿀 때

교체 유형무엇이 바뀌고 무엇이 남는가
언어·프레임워크 교체
Express → Spring
build/start만 수정. surfaces·test/는 그대로. 가장 순수한 형태의 포팅.
프로토콜 교체
REST → GraphQL
계약 자체가 바뀌므로 드라이버 호출부(스텝 구현)는 수정됩니다. 다만 .feature 문장은 그대로여야 합니다 — 시나리오가 바뀐다면 그건 포팅이 아니라 기능 변경입니다.
프론트 프레임워크 교체
React → Svelte
역할·라벨 기반 선택자와 data-testid가 유지되면 스텝도 그대로입니다. 여기서 선택자를 고쳐야 한다면 원래 시나리오가 프레임워크에 결합돼 있었다는 뜻입니다.
디자인 변경 동반 구조 단언은 유지되지만 시각 baseline은 무효화됩니다. baseline 갱신은 리뷰를 거친 명시적 승인으로 처리하세요 — 자동 갱신은 안전망을 스스로 지우는 일입니다.

에이전트에게 맡길 때도 같은 구조가 그대로 작동합니다. test/는 읽기 전용으로 주고 src/만 수정 권한을 준 뒤 belay run을 반복하게 하면, 채점 기준을 스스로 고칠 수 없는 상태에서 구현만 수렴시키게 됩니다.

이 문단은 가장 약한 고리에서 실측되었습니다. 실제 서비스(RealWorld 레퍼런스 구현, Express + Prisma + PostgreSQL)를 시나리오 50개로 고정해 두고, Haiku급 에이전트에게 test/ 읽기 전용 조건으로 Rust 재작성을 맡겼습니다. 결과는 수렴 — 약 14회의 실행, 25분 만에 50/50, 문서에 없던 동작 세 가지도 실패 목록이 요구했기 때문에 정확히 복제되었고, 채점 기준은 손대지 않았습니다. 측정된 단서 하나: 작은 모델은 실패를 남긴 채 일찍 멈춥니다. 실패 목록이 주는 것은 방향이고, 루프를 계속 돌리는 일은 로프를 쥔 쪽의 몫입니다.

16 결과와 신뢰

통과했다는 사실보다, 통과를 믿을 수 있다는 사실이 중요합니다.

실행마다 남는 증거

스텝 타임라인, 프로토콜 전문, 스크린샷·시각 diff·비디오, SUT/인프라 로그, 실패 시점 DOM과 콘솔. 실패를 재현하려고 다시 돌릴 필요가 없어야 합니다.

비결정적 판정은 분리 표기

시각 해석 결과는 통과/실패와 섞이지 않고 경고로 따로 집계됩니다. 모델 응답 원문과 근거 이미지가 함께 남아 사람이 판단할 수 있습니다.

플레이키는 실패로 취급

재시도로 초록불을 만들지 않습니다. 불안정한 시나리오는 격리 표시 후 원인을 고칩니다 — 흔들리는 안전망은 없느니만 못합니다.

미구현 스텝은 침묵하지 않는다

정의되지 않은 스텝, 스킵된 시나리오는 결과에 명시되고 커버리지에서 차감됩니다. 조용한 통과가 가장 위험합니다.

기계가 읽는 출력

junit.xmljson은 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, 선택적 peer kafkajs): 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등반에서 추락하는 동료를 로프로 잡아 주는 확보 행위. 이 프레임워크의 이름이자 역할.
SUTSystem Under Test. src/에 놓인 검증 대상. Belay는 이것의 내부를 알지 못한다.
Surface테스트가 SUT를 두드릴 수 있는 공개 창구. HTTP, CLI, 큐 등. 설정에 선언된 것만 존재한다.
FeatureGherkin으로 쓰인 실행 가능한 명세 파일. 사람이 읽는 스펙이자 기계가 돌리는 검증.
Step DefinitionGherkin 문장을 드라이버 호출로 번역하는 TypeScript 구현. 얇게 유지한다.
Driver하나의 Surface를 감싼 어댑터. 도메인 로직을 담지 않는다.
Exchange드라이버가 반환하는 정규화된 교환 결과. 프로토콜이 달라도 같은 모양이므로 공통 스텝을 쓸 수 있다.
Semantic Locator역할·라벨·접근 가능한 이름으로 화면 요소를 찾는 방식. 프레임워크가 바뀌어도 살아남는다.
Visual Baseline승인된 기준 스크린샷. 커밋되며, 갱신은 리뷰를 거친 명시적 승인으로만 이루어진다.
Visual Interpretation캡처 이미지를 모델이 읽어 내는 판단. 비결정적이므로 기본은 게이트가 아닌 경고.
Control Plane시계·시드·장애 주입처럼 바깥에서 만들 수 없는 상태를 만드는, 선언된 제어 표면.
Mock InfraSUT가 의존하는 주변 환경의 테스트용 대역. DB, 브로커, 외부 API 스텁. Belay가 띄운 것이므로 자유롭게 조작 가능.
Layer시나리오 계층(L0~L3). 실행 순서이자 실패를 해석하는 문법.
Baseline포팅 전 현재 구현으로 확보해 둔 전 계층 통과 기록. 재작성의 기준선.