
AI가 만든 테스트는 아무것도 테스트하지 못했다 —— 테스트하기 쉬운 설계의 중요성을 깨닫기까지
요약
AI가 생성한 테스트 코드가 실제 사양을 검증하지 못하는 문제를 다룹니다. 테스트 코드의 기교보다 테스트하기 쉬운 프로덕션 코드 설계와 책임 분리가 중요함을 강조합니다.
핵심 포인트
- 테스트 통과와 사양 검증은 별개임
- 테스트가 어렵다면 프로덕션 코드의 책임을 재검토해야 함
- 과도한 Mock 사용은 검증 대상의 핵심 로직을 가릴 수 있음
- 테스트 용이성은 코드의 경계 설계에서 결정됨
AI에게 테스트를 작성해 달라고 했습니다.
생성된 테스트는 모두 통과했지만, 리뷰에서 "이 테스트는 확인하고 싶은 것을 아무것도 검증하지 못하고 있는 것 아닌가요?"라는 지적을 받았습니다.
의미 있는 테스트로 고쳐보려 했지만 잘 되지 않았고, 저는 결국 그 테스트를 삭제했습니다.
하지만 정말로 다시 살펴봐야 했던 것은 테스트가 아니라, 프로덕션 코드 (Production Code)의 처리 단위였습니다.
이 글에서는 그 경험을 통해 배운 "테스트하기 쉬운 설계"에 대해 쓰겠습니다.
- 테스트가 통과하는 것과 사양 (Specification)을 검증하는 것은 별개다
- 테스트를 쓰기 어렵다면, 삭제하기 전에 처리의 책임 (Responsibility)을 재검토한다
- DB의 동작은 실제 DB로, 제어 로직은 모크 (Mock)로 확인한다
테스트 용이성은 테스트 코드의 기교가 아니라, 프로덕션 코드의 경계에서 생겨납니다.
예로 다음과 같은 배치 처리 (Batch Processing)를 생각해보겠습니다.
미처리 상태이면서 취소되지 않은 주문을 가져와서, 상태를 "발송 준비 중"으로 업데이트하는 처리입니다.
def process_pending_orders(session, batch_size=100):
while True:
stmt = (
...
AI가 생성한 테스트는 다음과 같았습니다.
def test_canceled_orders_are_not_processed():
session = MagicMock()
active_order = MagicMock()
...
테스트 이름은 "취소된 주문이 처리되지 않는 것"입니다.
일반 주문은 업데이트되고, 취소된 주문은 업데이트되지 않습니다. 겉보기에는 사양을 확인하고 있는 것처럼 보입니다.
여기서 프로덕션 코드에서 취소 조건을 실수로 삭제했다고 가정해 봅시다.
stmt = (
select(Order)
.where(Order.status == "pending")
...
운영 환경에서는 취소된 주문까지 가져오게 됩니다.
하지만 방금 전의 테스트는 성공합니다.
테스트 내부에서 DB가 반환할 주문을 미리 정해두었기 때문입니다.
session.execute.return_value.scalars.return_value.all.side_effect = [
[active_order],
[],
...
취소된 주문은 애초에 프로덕션 코드로 전달되지 않습니다.
따라서 업데이트되지 않는 것은 당연합니다.
이 테스트가 확인하고 있었던 것은, 가져온 주문을 업데이트할 수 있다는 점이었습니다.
확인하지 못했던 것은 테스트 이름이 주장하던 "취소된 주문을 DB에서 제외할 수 있다는 것"이었습니다.
제목에서는 "아무것도 테스트하지 않았다"라고 썼지만, 정확하게는 확인하고 싶었던 것을 테스트하지 못했다는 뜻입니다.
모크 (Mock) 자체가 나쁜 것은 아닙니다.
문제는 검증하고 싶은 DB의 동작까지 모크로 대체해 버렸다는 점이었습니다.
리뷰를 받고 실제 DB를 사용한 테스트로 다시 작성하려고 했습니다.
하지만 원래 함수에는 여러 가지 책임이 몰려 있었습니다.
DB의 검색 조건만 확인하고 싶어도 업데이트나 커밋 (Commit), 루프 (Loop)까지 함께 동작합니다.
루프의 종료 조건만 확인하고 싶어도 DB 액세스 (Access)가 따라옵니다.
즉, 다음 두 가지 선택지뿐이었습니다.
함수 전체를 실제 DB로 동작시키거나
또는
함수 전체가 동작하도록 의존성을 모크 (Mock) 처리하거나
후자를 선택하면 방금 전과 마찬가지로 모크가 늘어납니다.
당시의 저는 이것을 테스트 코드의 문제라고 생각했습니다.
의미 있는 테스트로 고칠 수 없다면, 애초에 불필요한 테스트가 아니었을까.
그렇게 생각하여 모크를 전제로 했던 테스트를 삭제했습니다.
모크를 사용한 테스트에는 의미가 없다
↓
실제 DB 테스트로도 고칠 수 없다
...
하지만 테스트를 지워도 취소된 주문을 제외할 수 있다는 보장은 늘어나지 않습니다.
사라진 것은 문제가 아니라, 문제를 볼 수 있는 위치였습니다.
필요했던 것은 테스트를 지우는 것이 아니었습니다.
의미 있는 테스트를 작성할 수 있도록 처리의 경계를 재검토하는 것이었습니다.
원래의 처리를 세 가지 책임으로 나눕니다.
def find_target_orders(session, batch_size):
stmt = (
select(Order)
...
처리 내용은 크게 변하지 않았습니다.
변한 것은 각각의 함수가 담당하는 범위입니다.
find_target_orders는 DB에서 어떤 주문을 가져올지만을 담당합니다.
process_batch는 가져온 주문을 업데이트하고 commit하는 것까지를 담당합니다.
run은 대상이 없어질 때까지 반복하는 것만을 담당합니다.
함수를 짧게 만드는 것 자체가 목적이 아닙니다.
검증하고 싶은 동작을 단독으로 추출할 수 있도록 만드는 것이 목적입니다.
find_target_orders에서 확인하고 싶은 것은 WHERE 절이 올바른지 여부입니다.
여기서는 모크 (Mock)를 사용하지 않고, 테스트 DB에 실제로 주문을 등록합니다.
def test_find_target_orders_excludes_canceled_orders(session):
active_order = Order(
status="pending",
...
이 테스트라면, 취소 조건을 삭제했을 때 실패합니다.
테스트명이 주장하는 사양과 실제로 통과하는 경로가 일치합니다.
반면, process_batch에서 확인하고 싶은 것은 가져온 주문을 업데이트하고 commit하는 것입니다.
이 테스트에서는 find_target_orders를 모크 (Mock) 처리해도 무방합니다.
DB 검색 조건은 이미 다른 테스트에서 확인했기 때문입니다.
@patch("batch.find_target_orders")
def test_process_batch_updates_orders(mock_find_target_orders):
order = MagicMock()
...
run도 마찬가지입니다.
process_batch가 2건, 1건, 0건을 반환하도록 모크 (Mock) 하면, 루프의 종료 조건과 합계 건수만을 확인할 수 있습니다.
책임별 테스트 방법을 정리하면 다음과 같습니다.
| 책임 | 테스트 방법 | 확인 사항 |
|---|---|---|
| DB에서 대상을 가져오기 | 실제 DB | WHERE 절, LIMIT |
| ... |
책임을 분리한 결과, 모크 (Mock)를 사용하지 않게 된 것은 아닙니다.
모크 (Mock)를 사용해도 좋은 곳과 실제 데이터로 확인해야 할 곳을 선택할 수 있게 되었습니다.
이번 실패를 통해 얻은 결론은 "모크 (Mock)를 사용해서는 안 된다"가 아닙니다.
모크 (Mock)는 제어 흐름 (Control Flow)이나 에러 처리를 확인할 때 유용합니다.
문제가 되는 것은 모크 (Mock)에 의해 검증 대상 그 자체를 대체해 버리는 것입니다.
SQL 조건을 확인하고 싶다
→ DB를 통한다
가져온 후의 업데이트를 확인하고 싶다
...
테스트 대상이 명확하다면, 모크 (Mock)를 사용할지 여부도 자연스럽게 결정됩니다.
이전에는 "테스트하기 쉬운 설계"를 함수를 작게 만들기 위한 일반론으로 받아들였습니다.
이번에 처음으로, 그것이 다음과 같은 설계라는 것을 실감할 수 있었습니다.
테스트하기 쉬운 설계란, 책임에 따라 적절한 검증 방법을 선택할 수 있는 설계이다.
AI는 현재 코드의 형태에 맞춰 테스트를 만듭니다.
하나의 함수에 DB 접근, 업데이트, commit, 루프가 들어있다면, 그 함수를 단독으로 실행하기 위해 많은 의존성을 모크 (Mock) 처리하는 것은 자연스러운 일입니다.
그렇기에 생성된 테스트를 보는 쪽에서 다음 두 가지를 확인해야 합니다.
- 이 테스트는 어떤 버그가 들어왔을 때 실패하는가
- 모크 (Mock)에 의해 무엇을 검증 대상에서 제외하고 있는가
SQL을 망가뜨려도 실패하지 않는다면, SQL을 보장하는 테스트가 아닙니다.
모크 (Mock)에 설정한 값을 프로덕션 코드 (Production Code)가 참조하지 않는다면, 그 값은 테스트를 위한 장식에 불과합니다.
테스트가 통과했다는 결과뿐만 아니라, 무엇을 망가뜨렸을 때 실패하는가를 보는 것이 중요합니다.
처음에는 AI가 생성한 테스트 코드에 문제가 있다고 생각했습니다.
그 후에는 의미가 없다면 삭제하면 된다고 생각했습니다.
하지만 정말로 재검토해야 했던 것은, 의미 있는 테스트를 작성할 수 없는 프로덕션 코드 (Production Code)의 경계였습니다.
의미 있는 테스트를 작성할 수 없다
↓
검증하고 싶은 책임을 추출할 수 없다
...
테스트를 삭제하더라도 검증되지 않은 사양은 그대로 남습니다.
모크 (Mock)를 없앤다고 해서 자동으로 좋은 테스트가 되는 것도 아닙니다.
테스트를 작성하기 어려울 때, 테스트를 지우기 전에 처리의 경계를 의심하라.
AI가 테스트 코드를 작성할 수 있게 되었기에, 그 테스트가 무엇을 보장하고 있는지를 판단하는 능력은 그 어느 때보다 중요해질 것이라고 느낍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기