테스트 14개 통과했는데, 정작 기능은 죽어있었다 — 초록불은 증거가 아니라 가설이다
TL;DR
- 단위 테스트 14개를 다 통과했는데, 사용자가 쓰는 핵심 동작(백그라운드 폴링)은 실제로는 한 번도 안 돌고 있었습니다.
- 우리가 테스트한 건 구현 디테일(헬퍼 함수) 이었지, 동작(behavior) 이 아니었습니다. TDD로 따지면 행위 중심 테스트가 빠진 겁니다.
- 이 구멍은 엄격한 멀티에이전트 QA가 메웠습니다. “내가 안 테스트한 경로”는 남이 파고드는 게 맞습니다.
들어가며
며칠 전, 헤르메스(hermes-agent)에 watch 툴을 하나 만들었습니다. 셸 명령을 일정 간격으로 돌리고, 조건이 맞으면 알림을 주는 녀석이죠. 이슈 #56694로 요청받은 기능이라 upstream에 기여하는 차원이었습니다.
테스트도 열심히 짰습니다. 조건 파서, 시간 파싱, 틱 플래너, 실행 래퍼 — 꼼꼼하게. 14개 전부 통과. 그래서 “오케이, 검증됐다” 하고 PR까지 냈습니다.
그런데 멀티에이전트 QA(여러 에이전트가 각자 메인테이너 빙의해서 리뷰하는 방식)를 돌려보니, 한 명이 이렇게 말하더군요.
“이거 백그라운드 폴링, 실제로는 안 도는데요?”
순간 멈췄습니다. 14개 다 통과했는데 안 돌았다니, 말이 안 되잖아요. 직접 증명해 보니 진짜 안 돌았습니다.
이 글은 그 과정을 솔직하게 적습니다. “테스트 짰는데 왜 안 돌아가?” — 개발자라면 누구나 한 번쯤 겪은 그 막막함에 대한 기록입니다.
우리가 한 테스트, 그리고 그 빈틈
처음 작성한 테스트는 크게 두 부류였습니다.
- 순수 헬퍼 단위 테스트 —
_eval_condition(조건 파서),_parse_duration(시간 파싱),_plan_ticks(틱 계획),run_once(실행 래퍼) 같은 내부 함수들을 직접 호출해서 결과를 봤습니다. - dispatch 래퍼 테스트 —
registry.dispatch("watch", ...)를 호출하고, 돌아온 결과 구조(필드名·타입)가 맞는지 확인했습니다.
둘 다 정상이었고, 14개 다 초록불이었습니다.
근데 빈틈이 있었어요. 테스트가 전부 폴백(fallback) 모드만 탔습니다. agent 컨텍스트가 없는 상태에서 돌려서 “1회 실행 결과”만 본 거죠. 정작 중요한 건 agent가 달린 상태에서 폴링 루프가 도는가 였는데, 그 경로는 단 한 번도 건드리지 않았습니다.
쉽게 말해, “문이 잘 열리나?” 테스트만 했지 “사람이 문 지나가서 밖으로 나가나?”는 안 봤습니다. 문은 멀쩡한데, 통로가 끊겨 있었던 거죠.
죽어있던 건 ‘동작’이었다
리뷰어가 짚은 근본 원인은 이겁니다.
registry.dispatch()가 핸들러에agent객체를 안 넘깁니다. 그래서 핸들러 안의kw.get("agent")가 항상None이 되고, 폴링 경로 대신 단일 실행(fallback) 분기 로 빠집니다.
결과가 딱 나옵니다.
- status 가
scheduled(폴링 대기) 가 아니라completed - observations 가 1개뿐 (폴링 안 돌고 1회만 실행)
- 즉 “interval/duration 동안 polling 한다” 는 핵심 기능 자체가 죽어있었습니다
저도 직접 돌려서 확인했습니다.
dispatch 결과 → status: completed, observations: 1
VERDICT: fallback only (no background polling)
그리고 덤으로 데드코드 도 있었습니다. WatchScheduler 라는 데몬스레드 엔진(~170줄)이 핸들러에서 단 한 번도 호출되지 않고 있었죠. 지워도 아무도 모를 코드였습니다. 검증은 “초록불”만 봤지, “이 코드가 호출되는가” 는 안 봤으니 당연히 살아남았습니다.
TDD로 보면, 어디가 잘못됐나
여기서 솔직해야 합니다. 우리는 엄밀한 TDD(빨강→초록→리팩터)를 안 했습니다. 구현 먼저, 테스트는 나중에 붙였죠. 그러니 “TDD가 실패했다” 고 쓰면 그건 거짓입니다.
맞게 쓰면 이렇습니다.
우리는 구현 디테일을 테스트했지, 동작을 테스트한 게 아니었다.
TDD(혹은 BDD)의 정신은 요구사항을 행위 단위로 먼저 테스트 하는 겁니다. 이 툴의 요구사항은 명확했죠. “watch 가 백그라운드에서 폴링하고, 조건 맞으면 알림을 준다.” 이걸 행위로 테스트했으면 어땠을까요.
# 이런 테스트를 먼저 썼다면?
res = registry.dispatch("watch", {"command": ..., "interval": 1, "condition": "UP"}, agent=agent)
assert res["status"] == "scheduled" # 폴링 대기 상태여야 함
# 살아있는 loop 위에서 폴링이 도는지, notify 가 가는지까지
이 테스트는 우리가 고칠 때까지 계속 빨강 이었을 겁니다. 그랬으면 1초 만에 알았겠죠. 초록불은 “네가 테스트한 경로” 만 보호할 뿐, 테스트하지 않은 경로는 아랑곳없이 죽어갑니다.
정리하면 이겁니다.
- 구현 테스트 —
_eval_condition이 잘 돌아감 ✅ (근데 그게 폴링과 연결되는지 모름) - 행위 테스트 — 폴링이 실제로 도는가 ❌ (없었음)
- TDD의 진짜 value 는 “행위부터 빨강으로 시작” 에 있습니다. 그래야 테스트가 사용자의 기대 동작 을 강제로 잡아두죠.
멀티에이전트 QA가 메운 구멍
이걸 잡은 건 결국 엄격한 멀티에이전트 QA 였습니다. pm·dev·infra·qa 네 명이 각자 메인테이너라고 생각하고 리뷰하게 했죠. (사실 1차엔 엉뚱한 경로를 봐서 허위 결함을 냈고, 경로 바로잡고 다시 돌려서 진짜를 잡았습니다 — 그건 다른 에피소드고.)
dev 가 정확히 짚은 게 포인트입니다.
“dispatch 가 agent 를 안 넘기니까, 핸들러는 항상 fallback 으로 빠집니다. 폴링은 dead 입니다.”
이건 내가 안 테스트한 경로 였습니다. 내가 테스트를 짤 때는 “당연히 agent 가 들어오겠지” 하고 가정했죠. 남은 가정을 남이 파고든 겁니다. 한 명이 가정 하나를 의심하고 실제로 돌려본 겁니다.
해결은 명확했습니다.
- 근본 원인 고침 — dispatch 호출부 3곳(
model_tools.py,tool_executor.py,agent_runtime_helpers.py)에agent=agent를 넘기도록 수정. 이제 핸들러가 agent 를 받아 백그라운드 루프를 켭니다. - 데드코드 삭제 — 호출 안 되던
WatchScheduler170줄 날림. - 진짜 검증 추가 — 살아있는 event loop 에서 폴링이 도는지 테스트:
status: matched, observations: 2, notify fired ✅ - ponytail 정리 — 폴링 로직이 3개 파일(86+9줄)에 흩어져 있어서, 핸들러 한 곳으로 흡수해 삭제.
- 구닥다리 테스트 파일(
tests/tools/...256줄, 이미 삭제한 데드코드를 테스트해서 7개 실패했던 녀석)도 청소.
최종 15 passed. PR #87441 로 upstream 에 올라갔습니다.
정리 — 초록불은 증거가 아니라 가설이다
이 사건에서 제가 남기고 싶은 한 줄입니다.
테스트가 초록불인 건, “그 기능이 된다” 는 증거가 아니라 “네가 테스트한 그 경로는 됐다” 는 가설의 확인일 뿐이다.
동작(behavior)을 테스트하지 않으면, 구현은 초록인데 제품은 죽어있는 괴리가 언제든 생깁니다. TDD 가 무조건 정답은 아니지만, “사용자가 기대하는 동작” 을 먼저 빨강으로 잡아두는 습관 만큼은 이번에 뼈저리게 배웠습니다.
그리고 혼자 테스트 짤 때 생기는 망각의 사각지대 는, 남이 “이거 진짜 돌아요?” 하고 의심하는 과정(엄격한 멀티에이전트 QA든, 동료 리뷰든) 없이는 잘 안 메워집니다. 내 가정은 내가 제일 못 의심하니까요.
다음에 툴 하나 더 만들면, 첫 테스트는 헬퍼가 아니라 “이게 사용자 앞에서 실제로 도는가” 부터 쓰기로 합니다. 늦게 배운 게 아니라, 제대로 배운 거니까요.
부록 — 이 글의 모든 코드/테스트 팩트는 실제 hermes-agent watch 툴 구현·PR #87441 과정에서 일어난 일입니다. 과장 없이, 실패한 부분도 그대로 적었습니다.
Comments