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)01을, @Max(100)100101을, @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로 생성한다.
- 색은 초록(통과) · 빨강(실패) · 노랑(계약 있는데 테스트 없음) · 회색(계약에 없는 테스트) 넷이다.
- 노랑을 없애는 것이 목표다. 초록 비율을 목표로 삼지 않는다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기