AI가 테스트를 빨리 짜준다. 그런데 통과를 봐도 안심이 안 된다.
이 글은 그 불안의 정체를 찾다가 나왔다. 결론부터 말하면 불안은 옳았고, 테스트를 더 짜는 것으로는 안 풀린다.
@Test
void 주문을_취소한다() {
Order order = orderRepository.save(new Order(PENDING));
mockMvc.perform(post("/orders/" + order.getId() + "/cancel"))
.andExpect(status().isOk());
}
이 테스트는 통과한다. 그런데 서버가 아무것도 안 하고 200만 돌려줘도 통과한다. 그리고 배송 중인 주문을 취소하면 어떻게 되는지, 남의 주문을 취소하면 어떻게 되는지는 아무 말도 안 한다.
주문 상태 5개 × 요청자 3종류면 15칸인데 이 테스트는 1칸이다. 14칸이 비어 있었는데 그게 눈에 안 보였던 것뿐이다.
먼저: 이미 정해져 있어 다시 고르지 않은 것
계약은 사람이 쓰고 이슈 본문에 산다. 에이전트 운영 표준이 계약을 C1~Cn으로 번호 매겨 적게 했고, 테스트 세션은 자기 이슈의 계약만 읽는다(원칙 12). 이 글은 그 계약의 모양만 바꾼다. 자리는 안 옮긴다.
검증은 세 층으로 갈려 있다. 검증 표준이 형식은 Controller 어노테이션, 비즈니스 규칙은 Service, DB를 봐야 하는 것은 Domain으로 나눴다.
제약의 단일 출처는 어노테이션이다. Swagger 문서화 표준이 @Min·@Max 같은 검증 어노테이션을 제약의 유일한 출처로 삼았다. 이게 아래 원칙 5의 재료다.
생성물은 손으로 고치지 않는다. 이 저장소가 always-on.rb·agents-skills.rb·ai-map.rb에서 세 번 쓴 패턴이고, 근거도 같다 — 원본이 하나면 어긋날 수가 없다.
원칙 1. 계약은 나열이 아니라 표로 적는다
나열은 빠진 것을 못 보여준다. 계약이 다섯 줄 적혀 있으면 그게 다섯 개로 충분한 건지 여섯 번째를 잊은 건지 구별할 방법이 없다.
안 A — 지금처럼 C1~Cn을 목록으로 적는다. 쓰기 쉽고 형식이 자유롭다. 버린 이유는 완전성을 사람의 기억력에 맡기는 것이다. 축이 둘만 돼도 조합이 십수 개인데, 그걸 빠짐없이 떠올리는 건 매번 성공할 수 없는 종류의 일이다.
안 B — 개수 하한을 정한다. “계약이 최소 다섯 개는 있어야 한다” 같은 규칙. 버린 이유는 그 숫자가 지어낸 값이라는 것이다. 다섯 개를 적어도 그게 맞는 다섯 개인지 아무도 모르고, 채우려고 의미 없는 계약을 쓰게 된다.
골랐다 — 안 C, 셀 수 있는 축을 잡아 표로 적는다.
| | PENDING | CONFIRMED | SHIPPED | DELIVERED | CANCELLED |
|--------|---------|-----------|---------|-----------|-----------|
| 본인 | C1 200 | C2 200 | C3 409 | − | C4 409 |
| 관리자 | C5 200 | C6 200 | C7 200 | − | C8 409 |
| 남 | C9 403 | − | − | − | − |
안 A와 안 B가 못 지켜주는 것은 빈칸이 보이는 것이다. 15칸 중 9칸을 채웠고 6칸이 −인 게 한눈에 들어온다.
다 채우는 것이 목표가 아니다. −는 “안 본다”는 결정이고, 그 결정을 적을 자리가 생기는 것이 이 표의 값이다. 잊은 것과 정한 것이 갈리는 순간 불안의 성질이 바뀐다.
대가는 축을 잘못 고르면 그 축 밖은 여전히 안 보인다는 것이다. 상태×역할 표를 다 채워도 “동시에 두 번 눌렀을 때”는 거기 없다. 그러니 표가 주는 것은 완전성이 아니라 고른 축 안에서의 완전성이고, 불안은 사라지는 게 아니라 「뭘 안 봤지」에서 「축을 잘 골랐나」로 옮겨간다. 후자는 남에게 보여주고 물어볼 수 있는 질문이라 그게 진보다.
원칙 2. 성공과 실패는 축이 아니라 칸의 값이다
표를 「정상 케이스 표」와 「예외 케이스 표」로 가르고 싶어진다. 가르면 안 된다.
성공/실패를 축으로 쓰려면 어떤 조합이 성공인지 미리 알아야 표를 그릴 수 있는데, 그게 바로 지금 정하려는 것이다. 순서가 뒤집힌다.
칸의 값이 곧 기대 응답이다
C1 200 성공
C3 409 실패 — 배송 후엔 취소 불가
C9 403 실패 — 남의 주문
같은 격자 안에 둘이 함께 있어야 대비가 보인다. 본인 × SHIPPED가 409인데 관리자 × SHIPPED가 200이라는 사실은 두 칸이 나란히 있어야 읽힌다. 표를 가르면 그 비교가 사라지고, 두 표 사이의 일관성은 아무도 검사하지 않는다.
원칙 3. 축의 성격이 다르면 표를 나눈다
성공/실패로는 안 나누지만, 나눠야 할 때가 따로 있다.
같은 취소 API인데 이 실패들은 상태×역할 격자에 안 들어간다.
잘못된 JSON 400 상태와 무관하다
없는 주문 ID 404 상태가 존재하지 않는다
결제 취소 API 타임아웃 504 외부 사정이다
동시에 두 번 눌렀다 ? 축이 「횟수」다
축이 다르면 표가 다르다. API 하나에 표가 서넛 나온다.
| 표 | 축 | 나오는 응답 |
|---|---|---|
| 업무 규칙 | 도메인 상태 × 요청자 | 200 · 409 · 403 |
| 입력 형식 | 필드 × 동등 분할 | 400 |
| 외부 연동 | 상대의 실패 종류 | 502 · 503 · 504 |
| 동시성 | 호출 횟수 · 순서 | 409 등 |
억지로 한 표에 우겨넣지 않는다. 축이 섞인 표는 빈칸이 무엇을 뜻하는지 알 수 없어져서, 표를 쓰는 이유가 사라진다.
원칙 4. 입력 공간은 동등 분할의 경계로만 센다
「입력 형식」 표는 다른 표와 성격이 다르다. 받을 수 있는 값이 사실상 무한이라 열거 자체가 안 된다.
그런데 동작이 갈리는 지점은 유한하다.
quantity: ... -2 -1 │ 0 │ 1 ... 100 │ 101 102 ...
↑ ↑ ↑
여기서만 동작이 바뀐다
-1과 -2는 같은 취급을 받으므로 하나만 보면 된다. 같은 취급을 받는 값의 묶음이 동등 분할이고, 묶음의 경계에만 테스트를 둔다. 무한한 입력이 여섯 줄로 줄어든다.
| 필드 | 분류 | 값 | 기대 |
|---|---|---|---|
quantity |
null | — | 400 |
quantity |
하한 미만 | 0 |
400 |
quantity |
하한 | 1 |
200 |
quantity |
상한 | 100 |
200 |
quantity |
상한 초과 | 101 |
400 |
quantity |
타입 오류 | "abc" |
400 |
묶음 안쪽을 더 찍지 않는다. 50을 테스트해도 1이 통과했다는 사실에 아무것도 더하지 않는다. 경계가 아닌 값을 늘리는 것은 테스트 수만 늘리고 증명은 안 늘린다.
원칙 5. 형식 검증의 행은 어노테이션에서 뽑고, 나머지는 사람이 쓴다
원칙 4의 표를 손으로 적을 필요가 없다. 경계가 이미 코드에 선언돼 있다.
public class CreateOrderRequest {
@NotNull @Min(1) @Max(100)
private Integer quantity;
}
@Min(1)이 0과 1을, @Max(100)이 100과 101을, @NotNull이 null 행을 만든다. 한 줄이 표의 여섯 행을 결정한다. Swagger 표준이 어노테이션을 제약의 단일 출처로 삼아뒀으므로 출처를 새로 만드는 것도 아니다.
다만 생성된 행은 순환적이다. @Max(100)에서 행을 뽑아 “101은 400”을 검증하면, 그건 Bean Validation이 동작한다는 것만 증명한다. 「100이 맞는 상한인가」는 증명되지 않는다.
그래서 생성이 주는 것은 증명이 아니라 목록이다.
생성이 하는 일 "이 여섯 가지를 물어봐야 한다"
사람이 할 일 "100 이 맞나" · "0 개 주문을 허용해야 하나"
빠뜨리지 않게 해주는 것이지 답을 주는 것이 아니다. 그래도 값이 있는 이유는 지금 문제가 「뭘 안 봤는지 모르겠다」이기 때문이다.
그리고 생성되는 건 세 층 중 한 층뿐이다.
| 층 | 예 | 행을 생성하나 |
|---|---|---|
| 형식 | @Min(1), @NotNull |
생성한다 |
| 비즈니스 규칙 | “배송 후엔 취소 불가” | 사람이 쓴다 |
| DB 확인 | “이미 가입된 이메일” | 사람이 쓴다 |
표가 생성 구역과 수기 구역으로 갈리는 것이 흠이 아니다. 어디까지 기계가 봐줬는지가 눈에 보이는 것이 이득이다.
원칙 6. 테스트는 상태 코드가 아니라 결과를 확인한다
표를 아무리 잘 그려도 칸을 채우는 테스트가 비어 있으면 소용없다.
// 이렇게 하지 않는다 — 서버가 아무것도 안 해도 통과한다
mockMvc.perform(post("/orders/1/cancel"))
.andExpect(status().isOk());
// 이렇게 한다
mockMvc.perform(post("/orders/1/cancel"))
.andExpect(status().isOk());
assertThat(orderRepository.findById(1L).getStatus())
.isEqualTo(CANCELLED);
마지막 줄이 없으면 아무것도 증명되지 않는다. cancel() 안을 통째로 지워도 앞의 두 줄은 통과한다.
이 규칙이 왜 필요한지는 뮤테이션 관점에서 분명해진다. 코드를 일부러 망가뜨렸을 때 우는 테스트만 무언가를 지키고 있는 것인데, 상태 코드만 보는 테스트는 무엇을 망가뜨려도 울지 않는다.
실패 칸도 같다. 409를 기대하는 칸이면 상태가 안 바뀌었는지까지 확인한다. 409를 주면서 뒤로는 취소해버리는 구현이 통과하면 안 된다.
원칙 7. 색칠된 표는 생성물이다
표가 있으면 “어디가 통과했나”를 색으로 보고 싶어진다. 그 색을 손으로 칠하면 안 된다.
손으로 칠하면 코드가 바뀌어도 색이 남는다. 그러면 그 표는 권위 있어 보이면서 거짓말을 하고, 없는 것보다 나쁘다. 이 저장소가 색인에 대해 이미 내린 판단과 같다 — 없으면 원본을 읽지만, 틀리면 틀린 것을 읽고 확신한다.
계약 표 (C1~Cn) ─┐
├──→ 생성기 ──→ 색칠된 HTML
테스트 결과 (JUnit XML) ─┘
색은 넷이다. 둘로는 부족하다.
| 계약에 있다 | 계약에 없다 | |
|---|---|---|
| 테스트 통과 | 초록 | 회색 — 계약 밖을 테스트하고 있다 |
| 테스트 실패 | 빨강 | 회색 |
| 테스트 없음 | 노랑 | 정상 (− 칸) |
노랑이 이 표준의 핵심이다. “계약에는 적었는데 테스트가 없는 칸” — 그게 처음의 불안이었고, 이제 사람의 성실함이 아니라 기계가 잡는다. @Max(100)을 새로 붙이는 순간 노랑 행 두 개가 저절로 생기고, 테스트를 짤 때까지 노랑으로 남는다.
초록/빨강만 칠하면 노랑 칸은 그냥 비어 보인다. 안 짠 건지 안 봐도 되는 건지 구별이 안 되고, 그러면 원래 문제로 돌아간다.
출력은 HTML로 한다. 색칠에 라이브러리가 필요 없고, CI 아티팩트로 떨어뜨리면 브라우저로 바로 열리고, 생성물이라 커밋하지 않아도 된다. 엑셀로 뽑는 안은 apache-poi 같은 의존이 붙고 git diff가 안 되는데, 생성물이라 diff가 필요 없다는 점을 고려해도 HTML보다 나은 게 없었다.
원칙 8. 칸과 테스트는 계약 번호로 잇는다
생성기가 “어느 테스트가 어느 칸인지”를 알아야 한다.
@Test
@Tag("C3")
void 본인이_SHIPPED_주문을_취소하면_409_이고_상태가_안_바뀐다() { ... }
JUnit XML에 태그가 실려 나오므로 생성기가 읽어 칸을 채운다.
별도 매핑 파일을 두지 않는다. 「테스트 이름 ↔ 계약 번호」 표를 손으로 유지하는 안을 버린 이유는, 이 저장소가 이미 같은 실패를 겪었기 때문이다 — 짝을 손으로 적는 곳이 생기면 그 줄을 빼먹었을 때 다시 조용히 통과한다. standards-pairing.rb를 만들면서 매핑 테이블 안을 버린 것과 같은 판단이다.
태그가 계약 번호 그 자체이므로 짝은 이름으로 성립하고, 틀린 번호를 달면 그 칸이 노랑으로 남아 드러난다.
판단 기준 정리
| 질문 | 답 | 왜 |
|---|---|---|
| 계약을 어떻게 적나 | 나열이 아니라 표 | 나열은 빠진 것을 못 보여준다 |
| 성공/실패를 축으로? | 아니다. 칸의 값이다 | 축으로 쓰면 답을 미리 알아야 표가 그려진다 |
| 표를 언제 나누나 | 축의 성격이 다를 때 | 축이 섞이면 빈칸의 뜻이 사라진다 |
| 무한한 입력은 | 동등 분할의 경계만 | 묶음 안쪽은 증명을 안 늘린다 |
| 형식 검증 행은 | 어노테이션에서 생성 | 경계가 이미 선언돼 있다 |
| 생성된 행이 증명인가 | 아니다. 목록이다 | 어노테이션대로 도는지만 본다 |
| 테스트는 무엇을 보나 | 상태 코드 + 결과 | 코드만 보면 무엇을 망가뜨려도 안 운다 |
| 색칠은 누가 | 생성기 | 손으로 칠하면 코드가 바뀌어도 남는다 |
| 색이 몇 개 | 넷 | 「계약 있는데 테스트 없음」이 보여야 한다 |
| 칸과 테스트를 잇는 법 | 계약 번호를 @Tag 로 |
매핑 파일을 손으로 적으면 빼먹는다 |
이 표준을 정하기까지
시작은 「통과해도 안심이 안 된다」였다. AI가 테스트를 빨리 짜주는데 그 통과가 못 미덥다는 것이었고, 처음엔 그게 E2E 비용 문제인 줄 알았다.
첫 갈림길에서 불안이 둘로 갈렸다. 하나는 “이 테스트가 진짜 뭘 잡긴 하나”(단위 쪽, 순환)이고 하나는 “이거 말고 안 본 게 있을 것 같다”(E2E 쪽, 불완전)였다. 둘은 처방이 다르다 — 앞은 뮤테이션으로 깨지고 뒤는 안 깨진다. 섞어 다루면 둘 다 안 풀린다는 걸 거기서 알았다.
그리고 뒤쪽 불안은 옳다는 걸 인정하고 나서 길이 났다. 사용자 여정의 공간은 무한하고 검증한 것은 유한하니 “안 본 게 있다”는 언제나 참이다. 더 짜서 그 느낌을 없애려는 시도는 원리적으로 실패한다. 목표를 「불안을 없앤다」에서 「불안이 붙는 자리를 옮긴다」로 바꾼 것이 이 표준의 방향을 정했다.
형식을 바꾸자는 결론은 이 저장소가 이미 한 번 발견한 것이었다. 에이전트 운영 표준이 이슈 본문을 자유 산문이 아니라 고정된 절로 만들면서 이렇게 적었다 — “빠진 것은 빈칸으로 보이지 않는다.” 그때는 이슈의 절 구조에 적용했고, 이번엔 계약의 축에 한 번 더 적용한 것이다.
입력 공간에서 뜻밖의 수확이 있었다. 무한한 입력을 어떻게 세느냐가 막혔는데, 동등 분할로 경계만 찍으면 유한해지고 그 경계가 이미 @Min·@Max에 선언돼 있었다. Swagger 표준이 어노테이션을 단일 출처로 삼아둔 덕에 새 출처를 만들 필요도 없었다. 표의 행을 생성할 수 있다는 게 이 대화에서 제일 실용적인 발견이었다.
다만 그 생성이 순환적이라는 것도 같이 봤다. 어노테이션에서 뽑은 행으로 어노테이션을 검증하는 것이라 「100이 맞는 상한인가」는 답하지 못한다. 그래서 생성물을 증명이 아니라 목록으로 격하시켜 적었다 — 이걸 안 적으면 6개월 뒤에 “형식 검증은 자동으로 다 된다”고 오해한다.
마지막에 색이 둘에서 넷으로 늘었다. 처음엔 통과/실패만 칠하려 했는데, 그러면 「계약은 있는데 테스트가 없는 칸」이 그냥 빈칸으로 보인다. 안 짠 건지 안 봐도 되는 건지 구별이 안 되니 원래 문제로 되돌아간다. 노랑을 넣는 순간 이 표가 대시보드가 아니라 검사 도구가 됐다.
정리
- 계약은 나열이 아니라 표로 적는다. 나열은 빠진 것을 보여주지 못한다
- 다 채우는 것이 목표가 아니다.
−는 “안 본다”는 결정이고, 그 결정을 적을 자리가 이 표의 값이다 - 성공과 실패는 축이 아니라 칸의 값이다. 가르면 두 표 사이의 일관성을 아무도 안 본다
- 축의 성격이 다르면 표를 나눈다. 업무 규칙 · 입력 형식 · 외부 연동 · 동시성
- 무한한 입력은 동등 분할의 경계로만 센다. 묶음 안쪽은 증명을 안 늘린다
- 형식 검증의 행은 어노테이션에서 생성한다. 다만 그건 증명이 아니라 목록이다
- 테스트는 상태 코드가 아니라 결과를 확인한다. 실패 칸에서는 상태가 안 바뀐 것까지 본다
- 색칠된 표는 생성물이다. 손으로 칠하면 코드가 바뀌어도 색이 남아 거짓말을 한다
- 색은 넷이고 노랑이 핵심이다. 계약은 있는데 테스트가 없는 칸이 기계로 드러나야 한다
- 칸과 테스트는 계약 번호(
@Tag)로 잇는다. 매핑 파일을 손으로 적으면 빼먹는다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/api-test-contract-table-standard/SKILL.md
---
name: api-test-contract-table-standard
description: API 테스트 계약을 표로 적고 결과를 색으로 생성하는 규칙. 이슈의 계약을 쓸 때, API 테스트를 짤 때, 무엇을 테스트해야 할지 정할 때 반드시 적용한다.
---
# API 테스트 계약표 표준
계약은 이슈 본문에 산다(에이전트 운영 표준). 이 표준은 그 계약의 **모양**과 **테스트와의 연결**을 정한다.
## 계약을 표로 적는다
- 계약을 목록으로 나열하지 않는다. **셀 수 있는 축을 잡아 마크다운 표로** 적는다.
- 칸에는 계약 번호와 기대 응답을 적는다. `C1 200`, `C3 409`.
- 검증하지 않기로 한 칸은 **`−`로 명시한다.** 비워두지 않는다. 표 아래 「범위 밖」 절에 그 이유를 적는다.
- 표를 다 채우는 것이 목표가 아니다. **빈칸이 보이게 하는 것**이 목표다.
- 성공과 실패를 별도 표로 가르지 않는다. **기대 응답은 칸의 값**이다.
## 축이 다르면 표를 나눈다
API 하나에 표가 여러 개 나온다. 억지로 한 표에 넣지 않는다.
- **업무 규칙** — 도메인 상태 × 요청자 → 200 · 409 · 403
- **입력 형식** — 필드 × 동등 분할 → 400
- **외부 연동** — 상대의 실패 종류 → 502 · 503 · 504
- **동시성** — 호출 횟수 · 순서
## 입력 공간은 경계로만 센다
- 받을 수 있는 값이 무한한 필드는 **동등 분할의 경계값만** 테스트한다.
- `@Min(1) @Max(100)`이면 행은 `null` · `0` · `1` · `100` · `101` · 타입 오류다.
- **묶음 안쪽 값을 추가하지 않는다.** `50`은 `1`이 통과했다는 사실에 아무것도 더하지 않는다.
- 형식 검증 행은 **DTO의 검증 어노테이션에서 뽑는다.** 손으로 적지 않는다.
- 생성된 행은 **증명이 아니라 목록**이다. 「100이 맞는 상한인가」는 사람이 판단한다.
- 비즈니스 규칙과 DB 확인이 필요한 계약은 생성할 수 없다. 사람이 쓴다.
## 테스트
- 테스트 메서드에 **계약 번호를 `@Tag`로 붙인다.** `@Tag("C3")`.
- 테스트 이름과 계약 번호의 매핑 파일을 따로 만들지 않는다.
- **상태 코드만 확인하지 않는다.** 실제 결과를 반드시 assert 한다.
- 실패를 기대하는 칸에서는 **상태가 바뀌지 않았음**까지 확인한다.
- 테스트 하나가 칸 하나다. 여러 칸을 한 테스트에 몰지 않는다.
## 결과 표
- 색칠된 표는 **생성물이다.** 손으로 칠하거나 고치지 않는다.
- 계약 표와 JUnit 결과를 입력으로 **HTML을 생성한다.** 엑셀로 만들지 않는다.
- 색은 넷이다.
- **초록** — 계약 있고 통과
- **빨강** — 계약 있고 실패
- **노랑** — 계약 있는데 **테스트가 없다**
- **회색** — 계약에 없는 테스트
- **노랑을 없애는 것이 목표다.** 초록 비율을 목표로 삼지 않는다.
- 생성물은 커밋하지 않는다. CI 아티팩트로 남긴다.
GitHub Copilot — .github/instructions/api-test-contract-table-standard.instructions.md
---
description: API 테스트 계약을 표로 적고 결과를 색으로 생성하는 규칙
applyTo: "**/*Test.java, **/*Tests.java"
---
# API 테스트 계약표
- 계약은 목록이 아니라 셀 수 있는 축을 잡은 마크다운 표로 적는다. 칸에 계약 번호와 기대 응답을 적는다.
- 검증하지 않는 칸은 `−`로 명시하고 이유를 적는다. 비워두지 않는다.
- 성공과 실패를 별도 표로 가르지 않는다. 기대 응답은 칸의 값이다.
- 축의 성격이 다르면 표를 나눈다. 업무 규칙 · 입력 형식 · 외부 연동 · 동시성.
- 입력이 무한한 필드는 동등 분할의 경계값만 테스트한다. 묶음 안쪽 값을 추가하지 않는다.
- 형식 검증 행은 DTO의 검증 어노테이션에서 뽑는다. `@Min(1) @Max(100)`이면 `null`·`0`·`1`·`100`·`101`·타입 오류다.
- 생성된 행은 증명이 아니라 목록이다. 경계값이 맞는지는 사람이 판단한다.
- 테스트 메서드에 계약 번호를 `@Tag("C3")`로 붙인다. 별도 매핑 파일을 만들지 않는다.
- 상태 코드만 확인하지 않는다. 실제 결과를 반드시 assert 한다.
- 실패를 기대하는 칸에서는 상태가 바뀌지 않았음까지 확인한다.
- 테스트 하나가 칸 하나다. 여러 칸을 한 테스트에 몰지 않는다.
- 색칠된 결과 표는 생성물이다. 손으로 칠하지 않고 계약 표와 JUnit 결과에서 HTML로 생성한다.
- 색은 초록(통과) · 빨강(실패) · 노랑(계약 있는데 테스트 없음) · 회색(계약에 없는 테스트) 넷이다.
- 노랑을 없애는 것이 목표다. 초록 비율을 목표로 삼지 않는다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기