테스트 이름과 달리 테스트를 통과한 여섯 가지 경우
요약
Claude Code를 활용하여 다중 테넌트 예약 API를 구축하고 테스트하는 과정에서, 이름과 달리 실제로는 제대로 검증되지 않은 여섯 가지 테스트 케이스가 발견되었습니다. 이는 녹색(green)으로 표시된 결과만으로는 테스트의 진정한 가치를 판단하기 어렵다는 점을 시사합니다.
핵심 포인트
- 테스트는 단순히 '녹색'으로 통과하는 것만으로는 부족하다.
- 부정적 주장이 있는 경우, 긍정적 제어(positive control)를 추가하여 검증해야 한다.
- 회귀 테스트가 아닌, 중요한 범위에서의 전제 조건(precondition)을 명확히 해야 한다.
- 단일 가드나 속성 실패 시에도 해당 기능이 독립적으로 테스트되어야 한다.
저는 최근 Claude Code를 사용하여 다중 테넌트(multi-tenant) 예약 API (FastAPI, SQLAlchemy, Postgres, Redis 및 백그라운드 워커)를 구축했으며, 이를 책의 실행 예제로 사용했습니다. 좋은 테스트 코드를 작성하는 것은 첫 커밋부터 명시적인 목표였습니다. 이 테스트 스위트는 실제 Postgres와 Redis에 대해 97개의 테스트로 성장했으며, 목킹된(mocked) 데이터베이스는 전혀 사용하지 않았습니다.
그중 여섯 개의 테스트가 이름이 주장하는 바를 실제로 테스트하지 않고도 통과했습니다. 그 어떤 것도 부주의한 것은 아니었습니다. 각각은 개연성이 있었고, 잘 읽혔으며, 녹색(green)으로 표시되었습니다. 녹색 실행 결과는 아무것도 확인하지 않는 테스트와 행동을 확인하는 테스트를 구별할 수 없으며, 이는 테스트를 작성할 때나 에이전트가 작성할 때나 마찬가지입니다.
다음은 우리가 발견한 순서대로 그 내용입니다.
1. "만료된 토큰은 거부됨"
이 테스트는 exp 값이 과거인 토큰을 생성하고 401 에러를 주장했습니다. 그리고 통과했습니다.
이는 하드 코딩된 시크릿(secret)으로 토큰에 서명했지만, 애플리케이션은 .env 파일의 다른 시크릿을 사용했습니다. 이 토큰은 만료가 아닌 서명 때문에 거부되었습니다. 만료 확인 기능을 꺼도 테스트는 여전히 통과했습니다.
해결책은 긍정적 제어(positive control)입니다. 부정적 주장이 있는 옆에, 동일한 토큰을 가져와 테스트 대상 속성만 변경하고 그것이 수락되는지 주장해야 합니다:
extired = jwt.encode(claims, settings.jwt_secret, algorithm="HS256")
assert post_with(expired).status_code == 401
...
만약 제어가 실패한다면, 부정적 테스트는 만료를 전혀 테스트하지 않았다는 의미입니다.
2. "2,070자 웹훅 URL은 500이 아닌 422를 반환함"
컨트랙트 퍼징(Contract fuzzing)을 통해 실제 500 에러가 발견되었습니다: 2,049자에서 2,083자 사이의 URL은 URL 검증기를 통과한 후 2,048자 컬럼을 오버플로우시켰습니다. 회귀 테스트(regression test)는 2,560자 길이의 URL을 사용했습니다. 이 URL은 데이터베이스에 도달하기 전에 URL 검증기 자체의 길이 제한 때문에 거부되어 422를 받았습니다.
해결책: 테스트가 그 전제 조건(assert 2048 < len(url) < 2083)을 주장하므로, 중요한 범위에서 벗어날 수 없습니다.
3. "Redis가 다운되었을 때 로그인은 안전하게 실패함"
로그인(Login)은 IP별 및 이메일별 두 가지 속도 제한을 확인합니다. IP별 검사를 실패하는 open 상태로 변경해도 테스트는 녹색(green)을 유지했는데, 이는 여전히 이메일별 검사가 폐쇄(closed) 상태로 실패했기 때문입니다.
수정 사항: 가드(guard)마다 의도적으로 버그를 심고, 각 가드를 단독으로 테스트하는 테스트 경로를 만들었습니다. 회원가입(Registration)은 IP별 검사만 가지고 있었으며, Redis 장애 상황에 대한 테스트가 전혀 없었습니다.
4. "두 소유자가 서로 제거해도 소유자 수가 0이 될 수는 없다"
두 개의 스레드, 두 개의 요청, 하나의 단언(assertion). 보호 잠금(protective lock)을 제거했을 때 10번 중 10번 통과했습니다. 스레드들은 실제로 간섭하지 않았습니다.
수정 사항: 간섭이 일어나도록 강제합니다. 두 번째 데이터베이스 연결을 열고, 요청에 필요한 행들을 잠근 다음(lock), 요청을 시작하고, 마지막으로 커밋합니다:
with engine.connect() as other:
other.execute(text("SELECT 1 FROM memberships WHERE org_id = :o "
"AND role = 'owner' FOR UPDATE"), {"o": org_id})
...
잠금이 있는 상태에서는 3번 중 3번 통과합니다. 잠금이 없으면 3번 중 3번 실패합니다. 이것이 바로 레이스 테스트(race test)입니다.
5. "정확히 12시간짜리 예약은 허용된다"
Hypothesis 속성 테스트는 -24시간부터 +24시간 사이의 무작위 기간을 생성하여 0 < duration <= 12h 규칙을 확인했습니다. 경계를 1 마이크로초만큼 이동(<=를 <로)해도 실패하지 않았습니다. 무작위 기간은 실제로 정확히 12:00:00에 떨어지는 경우가 거의 없습니다.
수정 사항: @example(duration=MAX_BOOKING)과 그 주변 값들로 경계를 고정합니다. 속성 테스트는 제공된 입력 공간의 가장자리에서는 매우 뛰어나지만, 내부의 가장자리에는 둔감합니다.
6. 단 하나의 예약도 가져오지 않은 테스트
이것은 거짓말을 하는 테스트가 아니라 누락된 테스트입니다. 리팩토링을 연습하던 중 GET /bookings/{id}를 깨뜨렸는데, 전체 테스트 스위트가 녹색 상태로 남아 있었습니다. 어떤 테스트도 단 하나의 예약을 성공적으로 가져온 적이 없었습니다.
여섯 가지 모두를 찾아낸 습관 하나
모든 것은 같은 방식으로 발견되었습니다: 의도적으로 코드를 깨뜨리고 테스트가 빨간색(red)으로 변하는지 확인하는 것입니다.
이를 스크립트로 만들었습니다. 이 스크립트는 현실적인 버그 목록(테넌트 필터 누락, 만료 미적용, 중복 제약 무력화, 오픈 실패를 하는 리미터 등)을 포함하고 있습니다. 각 버그를 차례로 적용하고, 이를 포착할 것으로 예상되는 테스트를 실행하며, 어떤 버그도 살아남으면 실패합니다. 현재는 22개가 있으며, 이 스크립트는 CI에서 실행됩니다. 보호 기능에 대한 테스트를 삭제하거나 약화시키는 풀 리퀘스트(pull request)는 남아있는 모든 테스트가 통과하더라도 빌드를 실패시킵니다.
이것은 일반적인 뮤테이션 테스팅 툴(mutation-testing tool)이 아닙니다. 이것은 이 특정 서비스가 절대 출시해서는 안 되는 버그들의 수작업으로 선별된 목록입니다. 각 버그는 오직 이를 포착할 것으로 예상되는 테스트 파일만 실행하기 때문에, 모든 풀 리퀘스트에 충분히 빠릅니다. 노트북 기준으로 22개 모두 약 1분 반 정도 걸립니다.
에이전트가 테스트를 작성할 때의 의미
AI가 작성한 테스트는 정확히 이러한 방식으로 실패하며, 다만 더 빠르고 더 많은 양으로 발생합니다. '철저하게 하라'는 어떤 지시보다도 결과에 영향을 미치는 세 가지 지침이 있습니다:
- "먼저 테스트를 작성하고, 그리고 그것이 실패하는 것을 나에게 보여주고, 그 이유를 설명해 줘." 실패 메시지는 설정 오류나 아직 존재하지 않는 경로(route)의 404가 아니라 행동(behaviour)을 명시해야 합니다.
- "모든 부정 테스트(negative test)에는 긍정적인 제어(positive control)가 필요하다."
- "새로운 보호 기능마다 의도적으로 버그를 심고 그것이 포착되는 것을 보여줘."
그러면 어떤 테스트가 빨간색으로 변했는지 읽으세요. 관련 없는 테스트에 의해 포착된 의도적인 버그는 성공이 아니라 경고입니다.
이는 제 책 『Claude Code for Backend Developers』의 9장 내용을 각색한 것입니다. 이 책에는 이 테스트들이 가져온 실행 가능한 서비스가 포함되어 있습니다: [https://azadhmhd.gumroad.com/l/claude-code-backend]
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기