지난 글에서 테스트가 깨지기도 전에 AI가 구현과 기대값을 함께 고치는 상황을 적었다. 그때는 빨간불을 볼 기회조차 없었다.

이번에는 분명히 빨간불이 켜졌다.

배포를 준비하던 PR에서 타입 검사는 통과했지만 테스트가 실패했다. 최근 main에 테스트를 추가한 PR들은 CI를 통과했다. 그러면 구현을 바꾼 쪽에 문제가 생긴 것일까. 처음에는 그렇게 의심했다.

로그와 변경 이력을 따라가 보니, 이야기가 조금 달랐다. 이번에 확인한 실패의 원인은 새 구현의 동작 결함이 아니라, 이전 구현을 전제로 만들어진 테스트가 새 구현과 맞지 않는다는 것이었다. 이 구분이 새 구현 전체에 결함이 없다는 뜻은 아니다.

그래서 이번에는 질문을 하나 더 붙이게 됐다.

테스트가 실패했다. 그런데 무엇을 지켜 낸 것인가?

초록색 결과에는 범위가 있었다

작업은 서로 다른 브랜치에서 진행되고 있었다.

한쪽에서는 사용자 상태를 읽는 방식을 바꾸고 있었다. 기존 값을 바로 읽는 대신 조회 훅을 거쳐 정책을 판단하고, 그 결과를 하위 로직에 전달하도록 바꿨다.

다른 쪽에서는 main의 기존 구현을 기준으로 선택 상태와 캐시 갱신 테스트를 추가했다. 당시 구현과 테스트 기대값은 맞았다. 그 PR들의 빌드, 타입 검사, 테스트도 통과했다.

이 두 흐름이 합쳐지자 테스트가 실패했다.

여기까지 보면 “각자 CI는 통과했는데 합치니 깨졌다”고 쓰기 쉽다. 하지만 확인한 사실은 그보다 좁았다.

대상 당시 검증한 것
main으로 들어온 테스트 PR들 기존 구현과 새 테스트를 함께 검증했고 통과했다
기능 브랜치로 들어온 구현 PR 대상 브랜치가 CI 필터에 포함되지 않아 빌드·타입 검사·테스트가 실행되지 않았다
배포를 준비하던 통합 PR 새 구현과 main의 테스트를 함께 검증했고 실패했다

한쪽은 통과했고 다른 쪽은 실행되지 않았다. 이것을 양쪽 모두 검증됐다고 말할 수는 없었다.

테스트 PR의 초록색 결과도 거짓은 아니었다. 당시 코드와 테스트의 조합에서는 유효했다. 다만 그 결과가 이후 합쳐질 코드까지 보증하지는 않았다.

실행 시간이나 캐시를 논하기 전에 먼저 확인할 것이 있었다. 어떤 코드 조합을 검증했는가. 실행되지 않은 검사가 통과한 것처럼 취급되고 있지는 않은가.

첫 실패는 비즈니스 규칙까지 도달하지 못했다

첫 번째 오류는 이런 것이었다.

useQuery is not a function

기존 단위 테스트는 쿼리 라이브러리를 모킹하고 있었다. 필요한 함수 몇 개만 넣어 둔 모의 객체(mock)였다. 이전 구현은 그것으로 충분했다.

새 구현에서는 호출 경로가 늘어났다. 아래는 구조만 남긴 예시다.

선택 처리 훅
  -> 정책 판단 훅
    -> 사용자 조회 훅
      -> useQuery

하지만 테스트의 mock에는 useQuery가 없었다. 선택 결과가 맞는지 비교하기도 전에 실행이 멈췄다.

다른 테스트에서는 다음 오류가 났다.

No QueryClient set, use QueryClientProvider to set one

초기화 훅이 조회 상태를 읽기 시작했는데, 테스트 환경에는 그 조회를 위한 QueryClientProvider가 없었다.

두 오류 모두 실제로 확인한 실패다. 그렇다고 사용자 화면에서도 같은 오류가 난다고 바로 결론 내릴 수는 없다. 필요한 의존성이 테스트 환경에서만 빠진 것인지, 실제 화면에서도 연결이 누락된 것인지 구분해야 한다.

이 사례에서는 단위 테스트의 조회 의존성을 격리하는 수정으로 해당 실패를 해소했다. 운영 코드의 결함을 고친 결과는 아니었다.

그러니 빨간불 자체는 유용했다. 통합된 코드와 테스트 환경이 맞지 않는다는 사실을 발견했다. 다만 그것을 곧바로 “회귀 결함을 잡았다”는 성과로 세면 다른 이야기가 된다.

모킹만 고치면 끝날 줄 알았다

나는 AI와 함께 조회 훅을 직접 모킹하도록 테스트를 고쳤다. 선택 상태를 검증하는 테스트가 사용자 조회의 설정까지 끌어안지 않도록, 그 경계에서 반환값을 정해 주는 방식이었다.

그런데 첫 수정은 부족했다. 조회 의존성만 정리한 상태로 올렸고, 이전 기대값이 남아 있었다. 전체 검증을 끝내기 전에 수정이 충분하다고 판단한 셈이다.

다시 실행하니 다음 불일치가 드러났다.

이전 테스트는 하위 함수에 사용자 등급이 전달되기를 기대했다. 새 구현은 이미 판단한 정책 값을 전달하고 있었다. 이름을 바꾼 축약 예시로 보면 이렇다.

// 이전 호출 계약
rebuildState({ items, level });

// 변경한 호출 계약
rebuildState({ items, isEligible });

이제 오류는 누락된 함수나 Provider가 아니라, 무엇을 전달해야 하는가에 대한 불일치였다.

여기서 AI에게 “현재 코드에 맞춰 기대값을 바꿔 줘”라고만 하면 금방 끝난다. 하지만 그 지시만으로는 새 계약이 맞는지 판단하는 과정이 빠진다. 구현이 잘못됐더라도 기대값을 그에 맞추면 테스트는 통과할 수 있다.

먼저 계약 변경이 의도된 것인지 확인했다. 그다음 기대값을 바꾸고, 정책 값이 참인 경우와 거짓인 경우 모두 하위 함수로 전달되는지 확인하도록 보완했다. 수정 후에는 해당 앱의 타입 검사와 전체 테스트를 실행해 통과를 확인했다.

이 검증의 한계도 있다. 정책 값을 정확히 전달한다는 테스트는 정책 판단 자체가 옳다는 증거가 아니다. 조회 결과로 정책을 계산하는 로직과, 그 정책을 받아 결과를 만드는 로직에는 각각의 검증이 필요하다.

이번 수정으로 테스트의 역할은 더 명확해졌다. 그렇다고 과정이 처음부터 매끄러웠던 것은 아니다. 첫 수정에서 놓친 기대값을 두 번째 수정에서 정리했다.

실패를 고치기 전에 종류부터 나눈다

같은 빨간불이어도 해야 할 일이 다르다.

실패의 종류 먼저 확인할 것 수정 방향
의도하지 않은 동작 변경 기존 요구사항과 결과가 어긋났는가 구현을 고친다
의도한 계약 변경 변경 근거와 영향을 받는 호출부가 확인됐는가 기대값을 갱신하되 유지할 동작은 계속 검증한다
테스트 환경의 누락 mock·Provider·의존성이 실제 실행 환경과 어떻게 다른가 테스트 환경 또는 격리 경계를 고친다

처음부터 셋 중 하나로 깔끔하게 나뉘는 것은 아니다. Provider 오류가 실제 화면에서도 나는지 조사해야 할 수도 있다. 겉으로는 계약 변경처럼 보여도 요구사항을 놓친 회귀일 수 있다.

중요한 것은 분류를 마치기 전에 기대값부터 바꾸지 않는 것이다.

이번에는 테스트 환경의 누락을 고치고 나서 계약 불일치가 드러났다. 처음 오류 하나만 없앴다고 끝낼 수 없었다.

AI에게 맡길 때도 같은 순서를 요구하려 한다. 실패한 테스트를 먼저 실행하고, 실패 지점을 설명하고, 기존 기대값이 지키던 계약을 확인한 뒤 수정한다. 수정 후 통과했다는 결과와, 기대값을 왜 바꿔도 되는지에 대한 설명은 별개다.

무엇을 모킹하고, 무엇을 남길 것인가

선택 상태 테스트가 사용자 조회의 모든 세부 사항을 알아야 하는 것은 아니다. 조회 방식이 바뀔 때마다 선택 상태 테스트까지 고쳐야 한다면 테스트가 관심사 밖의 구현에 너무 깊게 의존하고 있을 수 있다.

반대로 정책 훅을 모킹했으니 실제로도 잘 연결될 것이라고 생각해서는 안 된다. 격리한 경계는 다른 곳에서 검증해야 한다.

내가 나누고 싶은 역할은 이렇다.

  • 조회·정책 테스트: 입력과 조회 상태에 따라 정책을 올바르게 판단하는가.
  • 호출 경계 테스트: 판단한 값을 소비처에 빠짐없이 전달하는가.
  • 결과 테스트: 전달받은 정책이 참·거짓일 때 실제 결과가 요구사항과 맞는가.
  • 연결 검증: 실제 Provider와 훅을 함께 사용했을 때 정상적으로 실행되는가.

이것을 전부 별도 파일로 만들자는 뜻은 아니다. 위험과 기존 검증 범위를 보고 필요한 부분을 남기자는 것이다. 단위 테스트마다 조회 환경을 복제할 필요도 없고, 모든 조회를 모킹한 뒤 연결 검증을 비워 둘 이유도 없다.

함수 호출 인자 검증도 무조건 나쁜 테스트는 아니다. 이번처럼 정책 전달이 빠지면 결과가 달라지는 경계에서는 의미가 있다. 다만 그 검증을 최종 동작까지 확인한 것처럼 설명하지 않아야 한다.

구글 테스팅 블로그의 Change-Detector Tests Considered Harmful은 구현을 그대로 따라 쓰고, 올바른 동작을 검증하지 않은 채 변경마다 깨지는 테스트의 유지 비용을 지적한다. 이 사례의 모든 테스트가 그렇다고 단정할 수는 없다. 대신 각 검증문(assertion)이 요구사항을 지키는지, 현재 구현의 모양만 지키는지 다시 묻게 된다.

테스트를 만드는 속도와 유지하는 비용은 다르다

테스트를 추가한 PR은 작은 작업으로 보일 수 있다. 운영 코드를 거의 건드리지 않고, CI도 통과하고, 리뷰도 빠르게 끝난다.

하지만 그때 끝난 것은 작성과 첫 검증이다. 다음 구현 변경에서 누가 모킹을 고칠지, 기대값 변경의 정당성을 누가 확인할지, 합쳐진 코드에서 다시 무엇을 실행할지는 남아 있다.

그 비용을 코드를 통합하는 사람이 한꺼번에 떠안으면 테스트가 생산성을 막는다는 말이 나온다. 나는 이번에 그 답답함을 느꼈다.

그렇다고 실패한 테스트를 지우거나 CI를 건너뛰면 된다는 결론은 아니다. 줄여야 할 것은 검증이 아니라, 검증하는 내용 없이 반복되는 정비다.

테스트의 가치는 나중에 잡은 버그 수만으로 설명할 수 없다. 안전하게 구조를 바꾸거나 수동 확인을 줄이는 효과도 있다. 다만 케이스 수가 늘었다는 사실만으로 그런 효과가 생겼다고 말할 수는 없다.

구글의 Code Coverage Best Practices도 커버리지를 유용하지만 간접적인 지표로 설명한다. 실행된 줄이 많다는 사실과 그 동작을 올바르게 검증했다는 사실은 같지 않다.

이번 작업에서도 전체 검증이 통과한 것은 확인했다. 테스트 덕분에 절약한 시간이나 추가로 든 유지 비용을 수치로 측정한 것은 아니다. 그래서 생산성이 몇 배 좋아졌다는 말 대신, 어디에서 다시 판단이 필요했는지 기록하려 한다.

코드뿐 아니라 비교 기준도 정리했다

뒤이어 후속 PR을 정리하는 일도 있었다. 앞 작업이 main에 들어간 뒤에도 PR 화면에는 이전 변경이 많이 남아 있는 것처럼 보였다.

실제로 최신 main과 비교한 차이는 이미 후속 작업에 해당하는 변경뿐이었다. 최신 main을 병합해 비교 기준을 갱신했고, 그 병합 전후의 파일 내용이 같다는 것을 확인했다.

큰 숫자를 보고 코드를 잘못 고쳤다고 판단했다면, 필요 없는 되돌리기나 재작업을 할 수도 있었다.

테스트의 빨간불과 PR의 변경량은 서로 다른 신호다. 그래도 둘 다 같은 질문을 요구했다. 지금 어떤 기준과 무엇을 비교하고 있는가.

이번에는 빨간불 뒤의 질문을 남긴다

예전에 테스트와 CI가 진짜 생산성인 이유를 썼다. 그 생각을 버린 것은 아니다. 여전히 빠르게 코드를 쓰는 것보다, 바꾼 코드가 의도대로 동작하는지 빠르게 확인할 수 있는 것이 중요하다.

다만 초록색이 있다고 안심할 수 있는 것도 아니고, 빨간색이 있다고 결함을 잡았다고 말할 수 있는 것도 아니다.

이번 테스트는 합쳐진 코드와 테스트 계약의 불일치를 알려 줬다. 그 사실을 무시하지 않고, 환경 누락과 의도된 계약 변경을 구분해 고쳤다. 내가 너무 일찍 끝났다고 생각했던 첫 수정도 다시 고쳤다.

지난 글에서는 “이 테스트가 깨지면, 누가 보나요?”라고 물었다.

이번에는 그다음 질문이다.

“봤습니다. 그런데 이 실패는 무엇을 알려 줬나요?”

테스트가 배포 준비를 막았다는 사실만으로 충분하지 않다. 그 멈춤이 어떤 판단을 가능하게 했고, 다음에는 무엇을 덜 고쳐도 되게 만들었는지까지 알아야 한다.

사례의 이름과 코드 조각은 공개용으로 바꿨다. 코드 조각은 호출 계약을 설명하기 위한 축약 예시이며, 별도로 실행한 재현 코드는 아니다.