계약 커버리지 게이트 표준을 만들면서 유예 여덟 건을 남겼다. 계약은 적어뒀는데 테스트를 못 짜는 칸들이었고, 이유는 이랬다.
@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;
한 줄씩 따라가면, @IntegrationTest는 Testcontainers로 진짜 DB를 띄우고 스프링 전체를 구동한다는 뜻이고, @Transactional은 테스트가 끝나면 그 안에서 한 작업을 전부 트랜잭션 단위로 되돌린다(롤백)는 뜻이다 — 그래야 테스트마다 DB가 깨끗한 상태에서 시작한다. @Autowired는 스프링에게 “이 자리에 쓸 객체를 대신 만들어서 넣어 달라”고 부탁하는 표시이고, MockMvc는 서버를 실제로 띄우지 않고도 Controller에 가짜 요청을 보내 테스트할 수 있게 해 주는 도구다.
안 A가 못 지켜주는 것은 내부를 바꿔도 안 깨지는 것이다. Service를 쪼개든 Repository 구현을 갈든, 밖에서 보이는 계약이 그대로면 테스트는 그대로다. 고칠 테스트가 줄어드는 게 아니라 고칠 이유가 줄어든다.
대가는 셋이다. 느리다(컨테이너가 뜬다), 실패 범위가 넓다(어디서 깨졌는지 좁히는 데 시간이 든다), 그리고 Docker가 없으면 못 돈다. 셋 다 실재하지만, 앞의 둘은 한 번 겪을 때 비싸고 리팩터링 비용은 매번 든다.
원칙 2. 스텁이 통과시키는 것은 검증되지 않은 것이다
원칙 1의 근거를 「유지보수 비용」으로만 적으면 절반이다. 스텁은 비용 문제이기 전에 정확도 문제였다.
계약 테스트를 실제 스택으로 옮기자 그때까지 초록이던 자리에서 이게 나왔다.
ERROR: insert or update on table "tb_competitor_site"
violates foreign key constraint "fk_competitor_site_created_by"
여기서 외래 키 제약은 한 테이블의 컬럼이 다른 테이블에 실제로 있는 값만 가리킬 수 있게 거는 규칙이다. tb_competitor_site의 등록자 컬럼은 관리자 테이블에 실제로 있는 계정을 가리켜야 하는데, 스텁은 그 규칙을 모르고 아무 문자열이나 통과시켰다.
등록자가 실재하는 관리자 계정이어야 한다는 규칙을 DB만 알고 있었다. 스텁은 아무 문자열이나 받아 저장한 척했고, 그래서 그 칸은 여덟 달 동안 초록이었다. 코드가 틀린 게 아니라 테스트가 그 규칙을 지나가지 않았던 것이다.
같은 모양이 유예 여덟 건에도 있었다.
| 유예 | 스텁이 한 일 | 실제 스택이 하는 일 |
|---|---|---|
401 다섯 |
인증을 아예 안 지남 | Security 필터가 막는다 |
삭제된 경쟁사 셋 |
없는 번호와 같은 실패로 답함 | @SQLRestriction이 지운 행을 감춘다 |
@SQLRestriction은 Hibernate(JPA를 구현한 도구)에게 “이 엔티티를 조회할 때마다 이 조건을 몰래 붙여라”고 말해 두는 애너테이션이다. 지워진 행에 삭제 시각 같은 값을 채워 두고 “그 값이 없는 행만 보여줘라”는 조건을 걸면, 지워진 행은 실제로 DB에 남아 있어도 조회에서는 안 보인다(소프트 삭제라고 부른다). 그런데 Repository를 스텁으로 두면 이 애너테이션 자체가 동작하지 않으므로, “삭제된 걸 조회하면 없다고 답하는지”는 스텁으로는 애초에 확인할 수 없다.
스텁으로 못 짜는 계약이 있다는 것은 하네스의 한계가 아니라 신호였다. 그 칸들은 「테스트하기 어려운 것」이 아니라 「그 층에서는 존재하지 않는 것」이다. 인증은 필터에 있고 소프트 삭제는 SQL에 있는데, 둘 다 없는 층에 세워 놓고 검증하려 했으니 닿을 리가 없다.
그래서 규칙은 이렇게 선다 — 스텁이 통과시키는 것은 통과한 것이 아니다. 초록을 세는 대신 그 초록이 무엇을 지나서 얻어진 것인지를 본다.
원칙 3. 단위 테스트는 그 층에서만 재현되는 것에 쓴다
원칙 1을 「단위 테스트를 쓰지 않는다」로 읽으면 안 된다. 없애는 게 아니라 자리를 좁히는 것이다.
단위 테스트가 맞는 자리는 실제 스택에서 재현하기 어렵거나, 재현해도 조합이 폭발하는 것이다.
// 이건 단위로 짠다 — 상태 전이 조합이 스물넷인데 API로 만들려면 스물네 번 등록해야 한다
AdminAccountDomainTest > 잠긴 계정은 만료 시각이 지나면 다시 열린다
// 이건 실제 스택으로 짠다 — 계약이 밖에서 보이는 약속이다
CompetitorContractTest > 404 · 관리자 · 삭제된 경쟁사
여기서 404는 「그런 주소가 없다」는 뜻의 HTTP 상태 번호다 — 삭제된 경쟁사를 조회했을 때 이 번호가 나와야 계약을 지킨 것이다.
가르는 질문은 「이 규칙이 밖에서 보이는가」다.
| 무엇 | 층위 | 왜 |
|---|---|---|
| API 계약(상태 코드·응답·인증) | 실제 스택 | 밖에서 보이는 약속이라 내부가 바뀌어도 그대로여야 한다 |
| 도메인 규칙의 조합·경계값 | 단위 | 조합이 많고, 밖으로 나오는 길이 하나라 API로는 다 못 만든다 |
| 순수 계산·변환 | 단위 | 협력자가 없어 스텁도 없다. 깨질 이유가 로직뿐이다 |
| 인증·트랜잭션·소프트 삭제 | 실제 스택만 | 그 층에 없는 것은 스텁으로 재현하면 가짜다 |
마지막 줄이 원칙 2가 준 것이다. 선택지가 아니라 금지다 — 그 자리를 단위로 짜면 초록이 거짓말을 한다.
「꼭 필요한 것」의 판정은 개수가 아니라 이유로 한다. 「단위 테스트 비율 몇 퍼센트」 같은 목표를 두지 않는다. 계약표 표준이 「초록 비율을 목표로 삼지 않는다」고 정한 것과 같은 이유로, 비율은 채우려 들면 채워지고 그 순간 뜻이 없어진다.
원칙 4. 준비도 실제 경로로 만든다
실제 스택으로 옮겨도 준비 과정을 Repository로 만들면 절반이 도로 스텁이 된다.
// 이렇게 하지 않는다 — 등록 경로를 건너뛰므로 FK 위반이 여기서도 안 잡힌다
competitorRepository.create(domain, "아무-문자열");
// 이렇게 한다 — 검증하려는 상태를 운영과 같은 길로 만든다
long competitorNo = createCompetitor(); // POST /api/v1/admin/competitors
여기서 POST는 서버에 새로운 것을 만들어 달라고 요청하는 HTTP 메서드다. createCompetitor()가 실제로 이 요청을 보내 운영에서 쓰는 것과 같은 경로로 경쟁사를 하나 등록한다.
준비가 다른 길로 만들어지면 검증하는 상태가 운영에서 실제로 만들어지는 상태와 다른 것이 된다. 「삭제된 경쟁사」를 DELETED_AT을 직접 채워 만들면 그건 삭제 API가 만드는 상태라는 보장이 없고, 그러면 그 칸이 검증하는 것은 계약이 아니라 내가 만든 픽스처다.
대가는 준비가 느려지고 길어진다는 것이다. 경쟁사 하나를 지운 상태로 만들려면 등록 요청과 삭제 요청을 실제로 보내야 한다. 그래도 이 길을 택하는 이유는 준비가 곧 다른 계약의 실행이기 때문이다 — 등록이 깨지면 준비 단계에서 먼저 터지고, 그건 손해가 아니라 조기 발견이다.
원칙 5. 하네스를 정당화한 전제는 만료된다
이 표준이 나온 계기가 규칙 하나로 남을 만했다.
standalone MockMvc를 고른 이유는 코드에 이렇게 적혀 있었다.
DB가 사내망에서만 닿으므로
@SpringBootTest대신 standalone MockMvc와 스텁 UseCase로 구성한다.
맞는 판단이었다. 그때는 통합 테스트를 짜면 사내망 밖에서 아무도 못 돌렸다. 그런데 그 뒤 Testcontainers가 들어왔고, 전제는 죽었는데 그 위에 선 결정만 남았다. 유예 여덟 건은 이미 없어진 제약이 만든 것이었다.
그래서 규칙은 이렇다 — 하네스를 고른 이유를 코드에 적고, 그 이유가 아직 유효한지 유예가 생길 때마다 확인한다.
유예를 적는 자리가 이미 그 확인을 강제하고 있었다. @Pending("...")에 이유를 쓰려면 왜 못 짜는지를 한 문장으로 적어야 하고, 그 문장이 하네스 탓이면 물어볼 것이 하나 남는다 — 그 하네스를 왜 골랐더라. 이번에 그걸 물었더니 답이 「이미 아닌 이유」였다.
이유를 안 적었으면 못 물었다. 계약 커버리지 게이트 표준이 유예에 이유를 필수로 만든 것이 여기서 값을 냈다.
원칙 6. 실제 스택은 두 층이고 계약 칸은 위층에서만 채워진다
원칙 1을 「@IntegrationTest를 붙였으면 됐다」로 읽으면 빈 자리가 생긴다. 같은 애노테이션을 붙이고도 지나는 층이 다르다.
// (1) HTTP 를 지난다 — 상태 코드가 나온다
mockMvc.perform(get("/api/v1/inquiries/42"))
.andExpect(status().isForbidden());
// (2) HTTP 를 안 지난다 — 자바 메서드 호출이라 상태 코드가 없다
contentSourceCommandUseCase.register(command);
둘 다 Testcontainers 위에서 돌고 둘 다 DB까지 간다. 그런데 (2)는 Security 필터도, 직렬화도, 예외를 상태 코드로 옮기는 자리도 지나지 않는다.
정해야 했던 건 이거였다 — 이 둘을 한 이름으로 부를 것인가.
안 A — 하나로 본다. 둘 다 「실제 스택」이다.
얻는 것은 규칙이 하나라는 것이다. 검사가 「@IntegrationTest를 붙였나」 한 줄로 끝난다.
버린 이유는 1:1 문의에서 실제로 새어 나갔다는 것이다. 그 도메인에는 UseCase를 직접 부르는 통합 테스트가 이미 있었고, 그래서 「실제 스택으로 덮여 있다」고 보였다. 그런데 HTTP 계약을 지고 있던 건 만료된 전제 위에 선 스텁 테스트였다. 아래층 테스트의 존재가 위층의 빈자리를 가린 것이다. 원칙 5가 잡으려던 만료를 원칙 1의 이름이 도로 숨겼다.
안 B — 아래층을 없애고 전부 HTTP로 올린다.
얻는 것은 층이 하나뿐이라 헷갈릴 일이 없다는 것이다. 모든 초록이 같은 강도가 된다.
버린 이유는 아래층에서만 보이는 것이 있다는 것이다. AUTO_INCREMENT 순번을 INSERT 직후 되받아오는지, Instant가 DATETIME 컬럼을 JPA와 MyBatis 양쪽으로 같은 값으로 왕복하는지, 동적 검색 XML이 실제로 파싱되는지 — 전부 HTTP로 올리면 응답 뒤에 가려진다. 깨져도 「200이 아니다」까지만 알고 어디서 깨졌는지는 모른다. 원칙 3이 조합 폭발을 이유로 단위를 남긴 것과 같은 모양이다.
골랐다 — 안 C, 두 층으로 갈라 부르고 계약 칸은 위층에만 채우게 한다.
| HTTP 계약 테스트 | 서비스 통합 테스트 | |
|---|---|---|
| 부르는 법 | mockMvc.perform(...) |
UseCase 를 직접 부른다 |
| 지나는 것 | Security 필터 · 직렬화 · 상태 코드 매핑 · DB | UseCase · 도메인 · DB |
| 계약 칸 | 채운다 | 못 채운다 |
| 맡는 것 | 밖에서 보이는 약속 | 순번 채번 · 시각 왕복 · 매퍼 XML · 제약 위반 |
가르는 근거는 취향이 아니다. 계약표의 칸에 적히는 값이 상태 코드이고(API 테스트 계약표 표준), 상태 코드는 HTTP 층에서만 생긴다. 아래층은 그 값을 만들 방법이 없으니 칸을 채울 자격이 원리적으로 없다.
안 A가 못 지켜주는 것은 빈 층이 드러나는 것이다. 두 이름이 있으면 「이 도메인에 통합 테스트가 있다」로 넘어가지 못하고 「계약 칸을 누가 채우고 있나」를 따로 묻게 된다.
대가는 둘이다. 이름이 하나 늘고, 새 테스트를 짤 때 어느 층인지 한 번 더 고른다. 다만 그 고르는 질문이 원칙 3의 「이 규칙이 밖에서 보이는가」와 같은 질문이라, 실제로 새로 생기는 판단은 아니다.
판단 기준 정리
| 질문 | 답 | 왜 |
|---|---|---|
| 새 테스트의 기본 층위는 | 실제 스택 | 내부가 바뀌어도 안 깨져 리팩터링을 막지 않는다 |
| 단위 테스트는 언제 | 조합이 많거나 밖에서 안 보이는 규칙 | 실제 스택으로는 조합이 폭발한다 |
| 인증·트랜잭션·소프트 삭제는 | 실제 스택만 | 그 층에 없는 것을 스텁으로 세우면 초록이 거짓말한다 |
| 스텁이 통과시켰다면 | 검증되지 않은 것이다 | 지나지 않은 규칙은 확인된 적이 없다 |
| 준비 데이터는 어떻게 | 실제 API 경로로 | 다른 길로 만들면 검증 대상이 픽스처가 된다 |
| 단위 비율 목표를 두나 | 아니다 | 비율은 채우려 들면 채워지고 뜻이 없어진다 |
| 유예가 생기면 | 하네스를 고른 이유가 아직 유효한지 본다 | 전제는 만료되는데 결정은 남는다 |
| 계약표의 칸을 채우려면 | HTTP를 지나는 테스트 | 칸의 값이 상태 코드인데 아래층은 상태 코드를 만들지 않는다 |
| UseCase를 직접 부르는 통합 테스트는 | 쓴다. 다만 계약 칸은 못 채운다 | 순번 채번·시각 왕복·매퍼 XML은 그 층에서만 보인다 |
| 「통합 테스트가 있으니 됐다」인가 | 아니다. 누가 계약 칸을 채우는지 따로 본다 | 아래층의 존재가 위층의 빈자리를 가린다 |
이 표준을 정하기까지
시작은 유예 여덟 건이었다. 계약 커버리지 게이트를 만들면서 「못 짜는 것」으로 남긴 칸들이고, 열린 항목으로도 적어뒀다 — “Security를 태우는 컨텍스트를 따로 둘지, 스텁이 두 실패를 구분하게 할지.”
둘 다 아니었다. 코드를 열어보니 두 유예가 같은 처방을 가리키고 있었다 — 401은 실제 필터 체인이, 삭제는 실제 DB가 필요했고, 그 둘은 @IntegrationTest 하나에 이미 다 있었다. 문제는 「어떻게 만들까」가 아니라 「왜 안 쓰고 있었나」였다.
그리고 안 쓰던 이유가 이미 만료돼 있었다. Javadoc에 “DB가 사내망에서만 닿으므로”라고 적혀 있었는데, 그 사이 Testcontainers가 들어와 있었다. 전제가 죽은 것을 아무도 안 봤고, 그 위에 선 결정만 여덟 개의 유예로 남아 있었다. 이 발견이 원칙 5가 됐다 — 그리고 이걸 물어볼 수 있었던 건 유예에 이유를 적게 해뒀기 때문이라, 앞 표준이 만든 장치가 바로 다음 표준에서 값을 냈다.
옮길 범위에서 한 번 갈렸다. 여덟만 통합으로 짜고 기존 여덟은 두는 안이 작업량으로는 훨씬 쌌다. 버린 이유는 계약표의 초록이 칸마다 다른 강도를 뜻하게 된다는 것이었다 — 어떤 칸은 컨트롤러만, 어떤 칸은 DB까지 지난 초록인데 표를 보는 쪽은 그 차이를 모른다. 색을 넷으로 늘린 이유와 정면으로 부딪혔다.
옮기고 나서 예상 못 한 것이 나왔다. 스텁으로 초록이던 등록 계약이 실제 스택에서 500으로 깨졌다 — CREATED_BY 외래키였다. 등록자가 실재하는 계정이어야 한다는 규칙을 DB만 알고 있었고, 스텁은 아무 문자열이나 받아 통과시키고 있었다. 이때 이 표준의 근거가 「유지보수 비용」에서 「정확도」로 옮겨갔다. 처음엔 “단위 테스트가 많으면 고칠 게 많다”는 비용 이야기였는데, 이 실패가 스텁 초록은 애초에 초록이 아니었다는 더 강한 이야기를 만들어 줬다.
마지막으로 준비 경로를 정했다. 데이터를 Repository로 만들면 빠르지만, 그러면 방금 잡은 FK 위반이 준비 단계에서도 안 잡힌다. 준비도 API로 보내기로 하자 준비가 곧 다른 계약의 실행이 됐다 — 느려진 대신 등록이 깨지면 준비에서 먼저 터진다.
원칙 6은 이 글을 쓰고 나서 다음 도메인에 적용하다가 나왔다. 1:1 문의에 계약표를 열려고 보니 그 도메인에는 이미 @IntegrationTest가 붙은 테스트가 있었다. 원칙 1대로면 덮여 있어야 했는데, 그 테스트는 UseCase를 직접 부르고 있었고 HTTP 계약을 지고 있던 건 원칙 5가 잡으라고 한 바로 그 만료된 전제 위의 스텁이었다. 여기서 알았다 — 「실제 스택」이라는 한 이름이 두 층을 덮고 있었고, 그래서 원칙 5가 잡으려던 것을 원칙 1의 이름이 도로 숨기고 있었다.
가르는 기준은 찾을 필요가 없었다. 계약표가 이미 갖고 있었다. 칸에 적히는 값이 상태 코드이므로, 상태 코드를 만들지 않는 층은 칸을 채울 자격이 원리적으로 없다. 취향으로 정한 경계가 아니라 앞 표준이 정해둔 값의 모양이 정해준 경계다.
두 층을 하나로 합치는 안은 양쪽 다 버렸다. 위로 합치면(전부 HTTP) 순번 채번과 시각 왕복이 응답 뒤에 가려지고, 아래를 인정하되 이름을 안 나누면 방금 겪은 은폐가 그대로 남는다. 원칙 3에서 단위 테스트를 없애지 않고 자리를 좁힌 것과 같은 판단이라, 이 표준은 층을 줄이는 표준이 아니라 어느 층이 무엇을 증명하는지 적어두는 표준이라는 게 여기서 분명해졌다.
정리
- 기본은 실제 스택을 지나는 테스트다. 단위를 기본으로 두면 로직이 바뀔 때 고칠 테스트가 같이 는다
- 스텁이 통과시키는 것은 검증되지 않은 것이다. FK도 인증도 소프트 삭제도 스텁은 그냥 통과시킨다
- 단위 테스트는 없애는 게 아니라 자리를 좁히는 것이다. 조합이 폭발하거나 밖에서 안 보이는 규칙에 쓴다
- 인증·트랜잭션·소프트 삭제를 단위로 짜지 않는다. 그 층에 없는 것을 세우면 초록이 거짓말한다
- 준비 데이터도 실제 API 경로로 만든다. 다른 길로 만들면 검증 대상이 계약이 아니라 픽스처가 된다
- 단위 비율 같은 숫자 목표를 두지 않는다. 채우려 들면 채워지고 그 순간 뜻이 없어진다
- 하네스를 고른 이유를 적고, 유예가 생길 때마다 아직 유효한지 본다. 전제는 만료되는데 결정은 남는다
- 실제 스택은 두 층이다. 계약 칸은 HTTP를 지나는 쪽에서만 채워진다 — 칸의 값이 상태 코드라서다
- 「통합 테스트가 있으니 됐다」로 넘어가지 않는다. 아래층 테스트의 존재가 위층의 빈자리를 가린다
자신만의 철학을 만들어가는 중입니다.
댓글남기기