계약 커버리지 게이트 표준을 만들면서 유예 여덟 건을 남겼다. 계약은 적어뒀는데 테스트를 못 짜는 칸들이었고, 이유는 이랬다.

@Pending("standalone MockMvc에 Security 필터가 붙지 않아 인증을 거치지 않는다")
@Pending("없는 번호와 실패 경로가 같아 UseCase를 스텁으로 두면 둘을 가를 수 없다")

둘 다 「하네스가 그 상태에 닿지 못한다」였다. 그래서 남은 질문은 하네스를 어떻게 고치느냐로 보였는데, 실제로 열어보니 고칠 것은 하네스가 아니라 층위를 고른 판단 쪽이었다.


먼저: 이미 정해져 있어 다시 고르지 않은 것

계약의 모양과 게이트는 끝났다. 계약은 enum이 소유하고(API 테스트 계약표 표준 원칙 9), 테스트 없는 계약은 빌드를 막고 못 짜는 것만 이유를 적어 유예한다(계약 커버리지 게이트 표준). 이 글은 그 유예가 왜 생기는지를 다룬다.

테스트가 결과를 확인해야 한다는 것도 끝났다. 계약표 표준 원칙 6이 “상태 코드만 보는 테스트는 무엇을 망가뜨려도 울지 않는다”로 정했다. 아래 원칙 1은 그 판단을 층위로 한 겹 옮긴 것이다.


원칙 1. 기본은 실제 스택을 지나는 테스트다

정해야 했던 건 이거였다 — 새 테스트를 짤 때 기본값이 무엇인가.

안 A — 단위 테스트를 기본으로 두고 통합은 필요할 때만. 흔한 기본값이고 얻는 것은 속도다. 컨테이너가 안 뜨니 한 테스트가 밀리초 단위로 끝나고, 실패했을 때 범위가 좁아 원인을 빨리 짚는다.

버린 이유는 비즈니스 로직이 바뀔 때 고쳐야 할 테스트가 같이 늘어난다는 것이다. 단위 테스트는 협력자를 스텁으로 세우므로, 그 협력자의 시그니처나 호출 순서가 바뀌면 검증하려던 규칙이 그대로여도 테스트가 깨진다. 테스트 수가 늘수록 이 비용이 선형으로 는다. 리팩터링을 막는 테스트는 리팩터링을 안 하게 만든다.

골랐다 — 안 B, 실제 스택을 지나는 테스트를 기본으로 둔다. 요청이 Security 필터부터 DB까지 실제로 지나간다.

@IntegrationTest      // Testcontainers Postgres + 실제 컨텍스트
@Transactional        // 매 테스트가 끝나면 롤백
class CompetitorContractTest {

    @Autowired
    private MockMvc mockMvc;

안 A가 못 지켜주는 것은 내부를 바꿔도 안 깨지는 것이다. Service를 쪼개든 Repository 구현을 갈든, 밖에서 보이는 계약이 그대로면 테스트는 그대로다. 고칠 테스트가 줄어드는 게 아니라 고칠 이유가 줄어든다.

대가는 셋이다. 느리다(컨테이너가 뜬다), 실패 범위가 넓다(어디서 깨졌는지 좁히는 데 시간이 든다), 그리고 Docker가 없으면 못 돈다. 셋 다 실재하지만, 앞의 둘은 한 번 겪을 때 비싸고 리팩터링 비용은 매번 든다.


원칙 2. 스텁이 통과시키는 것은 검증되지 않은 것이다

원칙 1의 근거를 「유지보수 비용」으로만 적으면 절반이다. 스텁은 비용 문제이기 전에 정확도 문제였다.

계약 테스트를 실제 스택으로 옮기자 그때까지 초록이던 자리에서 이게 나왔다.

ERROR: insert or update on table "tb_competitor_site"
violates foreign key constraint "fk_competitor_site_created_by"

등록자가 실재하는 관리자 계정이어야 한다는 규칙을 DB만 알고 있었다. 스텁은 아무 문자열이나 받아 저장한 척했고, 그래서 그 칸은 여덟 달 동안 초록이었다. 코드가 틀린 게 아니라 테스트가 그 규칙을 지나가지 않았던 것이다.

같은 모양이 유예 여덟 건에도 있었다.

유예 스텁이 한 일 실제 스택이 하는 일
401 다섯 인증을 아예 안 지남 Security 필터가 막는다
삭제된 경쟁사 없는 번호와 같은 실패로 답함 @SQLRestriction이 지운 행을 감춘다

스텁으로 못 짜는 계약이 있다는 것은 하네스의 한계가 아니라 신호였다. 그 칸들은 「테스트하기 어려운 것」이 아니라 「그 층에서는 존재하지 않는 것」이다. 인증은 필터에 있고 소프트 삭제는 SQL에 있는데, 둘 다 없는 층에 세워 놓고 검증하려 했으니 닿을 리가 없다.

그래서 규칙은 이렇게 선다 — 스텁이 통과시키는 것은 통과한 것이 아니다. 초록을 세는 대신 그 초록이 무엇을 지나서 얻어진 것인지를 본다.


원칙 3. 단위 테스트는 그 층에서만 재현되는 것에 쓴다

원칙 1을 「단위 테스트를 쓰지 않는다」로 읽으면 안 된다. 없애는 게 아니라 자리를 좁히는 것이다.

단위 테스트가 맞는 자리는 실제 스택에서 재현하기 어렵거나, 재현해도 조합이 폭발하는 것이다.

// 이건 단위로 짠다 — 상태 전이 조합이 스물넷인데 API로 만들려면 스물네 번 등록해야 한다
AdminAccountDomainTest > 잠긴 계정은 만료 시각이 지나면 다시 열린다

// 이건 실제 스택으로 짠다 — 계약이 밖에서 보이는 약속이다
CompetitorContractTest > 404 · 관리자 · 삭제된 경쟁사

가르는 질문은 「이 규칙이 밖에서 보이는가」다.

무엇 층위
API 계약(상태 코드·응답·인증) 실제 스택 밖에서 보이는 약속이라 내부가 바뀌어도 그대로여야 한다
도메인 규칙의 조합·경계값 단위 조합이 많고, 밖으로 나오는 길이 하나라 API로는 다 못 만든다
순수 계산·변환 단위 협력자가 없어 스텁도 없다. 깨질 이유가 로직뿐이다
인증·트랜잭션·소프트 삭제 실제 스택만 그 층에 없는 것은 스텁으로 재현하면 가짜다

마지막 줄이 원칙 2가 준 것이다. 선택지가 아니라 금지다 — 그 자리를 단위로 짜면 초록이 거짓말을 한다.

「꼭 필요한 것」의 판정은 개수가 아니라 이유로 한다. 「단위 테스트 비율 몇 퍼센트」 같은 목표를 두지 않는다. 계약표 표준이 「초록 비율을 목표로 삼지 않는다」고 정한 것과 같은 이유로, 비율은 채우려 들면 채워지고 그 순간 뜻이 없어진다.


원칙 4. 준비도 실제 경로로 만든다

실제 스택으로 옮겨도 준비 과정을 Repository로 만들면 절반이 도로 스텁이 된다.

// 이렇게 하지 않는다 — 등록 경로를 건너뛰므로 FK 위반이 여기서도 안 잡힌다
competitorRepository.create(domain, "아무-문자열");

// 이렇게 한다 — 검증하려는 상태를 운영과 같은 길로 만든다
long competitorNo = createCompetitor();   // POST /api/v1/admin/competitors

준비가 다른 길로 만들어지면 검증하는 상태가 운영에서 실제로 만들어지는 상태와 다른 것이 된다. 「삭제된 경쟁사」를 DELETED_AT을 직접 채워 만들면 그건 삭제 API가 만드는 상태라는 보장이 없고, 그러면 그 칸이 검증하는 것은 계약이 아니라 내가 만든 픽스처다.

대가는 준비가 느려지고 길어진다는 것이다. 경쟁사 하나를 지운 상태로 만들려면 등록 요청과 삭제 요청을 실제로 보내야 한다. 그래도 이 길을 택하는 이유는 준비가 곧 다른 계약의 실행이기 때문이다 — 등록이 깨지면 준비 단계에서 먼저 터지고, 그건 손해가 아니라 조기 발견이다.


원칙 5. 하네스를 정당화한 전제는 만료된다

이 표준이 나온 계기가 규칙 하나로 남을 만했다.

standalone MockMvc를 고른 이유는 코드에 이렇게 적혀 있었다.

DB가 사내망에서만 닿으므로 @SpringBootTest 대신 standalone MockMvc와 스텁 UseCase로 구성한다.

맞는 판단이었다. 그때는 통합 테스트를 짜면 사내망 밖에서 아무도 못 돌렸다. 그런데 그 뒤 Testcontainers가 들어왔고, 전제는 죽었는데 그 위에 선 결정만 남았다. 유예 여덟 건은 이미 없어진 제약이 만든 것이었다.

그래서 규칙은 이렇다 — 하네스를 고른 이유를 코드에 적고, 그 이유가 아직 유효한지 유예가 생길 때마다 확인한다.

유예를 적는 자리가 이미 그 확인을 강제하고 있었다. @Pending("...")에 이유를 쓰려면 왜 못 짜는지를 한 문장으로 적어야 하고, 그 문장이 하네스 탓이면 물어볼 것이 하나 남는다 — 그 하네스를 왜 골랐더라. 이번에 그걸 물었더니 답이 「이미 아닌 이유」였다.

이유를 안 적었으면 못 물었다. 계약 커버리지 게이트 표준이 유예에 이유를 필수로 만든 것이 여기서 값을 냈다.


판단 기준 정리

질문
새 테스트의 기본 층위는 실제 스택 내부가 바뀌어도 안 깨져 리팩터링을 막지 않는다
단위 테스트는 언제 조합이 많거나 밖에서 안 보이는 규칙 실제 스택으로는 조합이 폭발한다
인증·트랜잭션·소프트 삭제는 실제 스택만 그 층에 없는 것을 스텁으로 세우면 초록이 거짓말한다
스텁이 통과시켰다면 검증되지 않은 것이다 지나지 않은 규칙은 확인된 적이 없다
준비 데이터는 어떻게 실제 API 경로로 다른 길로 만들면 검증 대상이 픽스처가 된다
단위 비율 목표를 두나 아니다 비율은 채우려 들면 채워지고 뜻이 없어진다
유예가 생기면 하네스를 고른 이유가 아직 유효한지 본다 전제는 만료되는데 결정은 남는다

이 표준을 정하기까지

시작은 유예 여덟 건이었다. 계약 커버리지 게이트를 만들면서 「못 짜는 것」으로 남긴 칸들이고, 열린 항목으로도 적어뒀다 — “Security를 태우는 컨텍스트를 따로 둘지, 스텁이 두 실패를 구분하게 할지.”

둘 다 아니었다. 코드를 열어보니 두 유예가 같은 처방을 가리키고 있었다 — 401은 실제 필터 체인이, 삭제는 실제 DB가 필요했고, 그 둘은 @IntegrationTest 하나에 이미 다 있었다. 문제는 「어떻게 만들까」가 아니라 「왜 안 쓰고 있었나」였다.

그리고 안 쓰던 이유가 이미 만료돼 있었다. Javadoc에 “DB가 사내망에서만 닿으므로”라고 적혀 있었는데, 그 사이 Testcontainers가 들어와 있었다. 전제가 죽은 것을 아무도 안 봤고, 그 위에 선 결정만 여덟 개의 유예로 남아 있었다. 이 발견이 원칙 5가 됐다 — 그리고 이걸 물어볼 수 있었던 건 유예에 이유를 적게 해뒀기 때문이라, 앞 표준이 만든 장치가 바로 다음 표준에서 값을 냈다.

옮길 범위에서 한 번 갈렸다. 여덟만 통합으로 짜고 기존 여덟은 두는 안이 작업량으로는 훨씬 쌌다. 버린 이유는 계약표의 초록이 칸마다 다른 강도를 뜻하게 된다는 것이었다 — 어떤 칸은 컨트롤러만, 어떤 칸은 DB까지 지난 초록인데 표를 보는 쪽은 그 차이를 모른다. 색을 넷으로 늘린 이유와 정면으로 부딪혔다.

옮기고 나서 예상 못 한 것이 나왔다. 스텁으로 초록이던 등록 계약이 실제 스택에서 500으로 깨졌다 — CREATED_BY 외래키였다. 등록자가 실재하는 계정이어야 한다는 규칙을 DB만 알고 있었고, 스텁은 아무 문자열이나 받아 통과시키고 있었다. 이때 이 표준의 근거가 「유지보수 비용」에서 「정확도」로 옮겨갔다. 처음엔 “단위 테스트가 많으면 고칠 게 많다”는 비용 이야기였는데, 이 실패가 스텁 초록은 애초에 초록이 아니었다는 더 강한 이야기를 만들어 줬다.

마지막으로 준비 경로를 정했다. 데이터를 Repository로 만들면 빠르지만, 그러면 방금 잡은 FK 위반이 준비 단계에서도 안 잡힌다. 준비도 API로 보내기로 하자 준비가 곧 다른 계약의 실행이 됐다 — 느려진 대신 등록이 깨지면 준비에서 먼저 터진다.


정리

  • 기본은 실제 스택을 지나는 테스트다. 단위를 기본으로 두면 로직이 바뀔 때 고칠 테스트가 같이 는다
  • 스텁이 통과시키는 것은 검증되지 않은 것이다. FK도 인증도 소프트 삭제도 스텁은 그냥 통과시킨다
  • 단위 테스트는 없애는 게 아니라 자리를 좁히는 것이다. 조합이 폭발하거나 밖에서 안 보이는 규칙에 쓴다
  • 인증·트랜잭션·소프트 삭제를 단위로 짜지 않는다. 그 층에 없는 것을 세우면 초록이 거짓말한다
  • 준비 데이터도 실제 API 경로로 만든다. 다른 길로 만들면 검증 대상이 계약이 아니라 픽스처가 된다
  • 단위 비율 같은 숫자 목표를 두지 않는다. 채우려 들면 채워지고 그 순간 뜻이 없어진다
  • 하네스를 고른 이유를 적고, 유예가 생길 때마다 아직 유효한지 본다. 전제는 만료되는데 결정은 남는다

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/test-level-selection-standard/SKILL.md

---
name: test-level-selection-standard
description: 테스트를 실제 스택으로 짤지 단위로 짤지 고르는 규칙. 새 테스트를 짤 때, 스텁이나 목을 세우려 할 때, 테스트가 못 짜진다고 판단할 때 반드시 적용한다.
---

# 테스트 층위 선택 표준

단위 테스트를 기본으로 두면 비즈니스 로직이 바뀔 때 고칠 테스트가 같이 는다. 그리고 스텁은 실제 제약(외래키·인증·소프트 삭제)을 통과시켜 초록을 거짓말하게 만든다.

## 기본은 실제 스택이다

- 새 테스트의 기본 층위는 **실제 스택을 지나는 테스트**다. 요청이 Security 필터부터 DB까지 실제로 지나간다.
- `@IntegrationTest`(Testcontainers + 실제 컨텍스트)에 `@Transactional`을 붙여 매 테스트가 롤백되게 한다.
- 협력자를 스텁으로 세우기 전에 **실제 것을 쓸 수 없는 이유**를 댄다. 대지 못하면 실제 것을 쓴다.
- 속도를 이유로 단위를 고르지 않는다. 느린 것은 한 번 겪을 때 비싸고, 리팩터링 비용은 매번 든다.

## 스텁이 통과시키는 것은 검증되지 않은 것이다

- 스텁으로 초록인 칸은 **그 규칙을 지나간 적이 없다.** 외래키·유니크 제약·인증·트랜잭션 경계·소프트 삭제는 스텁이 전부 통과시킨다.
- **스텁으로 못 짜는 계약이 있으면 하네스의 한계가 아니라 신호다.** 그 계약이 사는 층에 테스트가 서 있지 않다는 뜻이다.
- 초록의 개수를 세지 않는다. 그 초록이 **무엇을 지나서 얻어졌는지**를 본다.

## 단위 테스트를 쓰는 자리

가르는 질문은 「이 규칙이 밖에서 보이는가」다.

| 무엇 | 층위 |
|---|---|
| API 계약(상태 코드·응답·인증) | 실제 스택 |
| 도메인 규칙의 조합·경계값 | 단위 |
| 순수 계산·변환 | 단위 |
| 인증·트랜잭션·소프트 삭제 | **실제 스택만** |

- 마지막 줄은 선택이 아니라 금지다. 그 층에 없는 것을 스텁으로 재현하면 초록이 거짓말한다.
- 단위 비율 같은 숫자 목표를 두지 않는다. 「꼭 필요한 것」은 개수가 아니라 이유로 판정한다.

## 준비도 실제 경로로 만든다

- 검증할 상태를 Repository나 SQL로 직접 만들지 않는다. **실제 API를 호출해 만든다.**
- 다른 길로 만들면 검증하는 상태가 운영에서 만들어지는 상태와 달라지고, 그러면 그 테스트는 계약이 아니라 픽스처를 검증한다.
- 준비가 느려지는 것은 감수한다. 준비가 곧 다른 계약의 실행이라, 등록이 깨지면 준비 단계에서 먼저 터진다.

## 하네스를 고른 이유는 만료된다

- 하네스(standalone·스텁·인메모리)를 고를 때는 **왜 그것을 골랐는지 코드에 적는다.**
- 유예(`@Pending`)가 생길 때마다 그 이유를 다시 본다. 유예 사유가 **하네스 탓이면 하네스를 고른 이유가 아직 유효한지 확인한다.**
- 전제가 사라졌으면 하네스를 옮긴다. 전제는 만료되는데 그 위에 선 결정은 남는다.

GitHub Copilot — .github/instructions/test-level-selection-standard.instructions.md

---
description: 테스트를 실제 스택으로 짤지 단위로 짤지 고르는 규칙
applyTo: "**/*Test.java, **/*Tests.java, **/*IntegrationTest.java"
---

# 테스트 층위 선택

- 새 테스트의 기본 층위는 실제 스택이다. `@IntegrationTest` + `@Transactional` 로 짜고, 요청은 Security 필터부터 DB까지 실제로 지나가게 한다.
- 스텁·목을 세우기 전에 실제 것을 쓸 수 없는 이유를 댄다. 대지 못하면 실제 것을 쓴다.
- 속도를 이유로 단위를 고르지 않는다.
- 스텁으로 초록인 칸은 그 규칙을 지나간 적이 없다. 외래키·유니크 제약·인증·트랜잭션·소프트 삭제를 스텁은 전부 통과시킨다.
- 스텁으로 못 짜는 계약은 하네스의 한계가 아니라 층위가 틀렸다는 신호다.
- 단위 테스트는 도메인 규칙의 조합·경계값과 순수 계산·변환에 쓴다.
- 인증·트랜잭션·소프트 삭제는 단위로 짜지 않는다. 그 층에 없는 것을 세우면 초록이 거짓말한다.
- 단위 테스트 비율 같은 숫자 목표를 두지 않는다.
- 준비 데이터를 Repository나 SQL로 직접 만들지 않는다. 실제 API를 호출해 만든다 — 다른 길로 만들면 계약이 아니라 픽스처를 검증하게 된다.
- 하네스를 고른 이유를 코드에 적는다. 유예가 생기면 그 이유가 아직 유효한지 확인하고, 전제가 사라졌으면 하네스를 옮긴다.

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

댓글남기기