초록불인데 아무것도 지키지 않는 테스트 — 항상 통과하는 단언 3종
테스트가 통과한다는 것과 테스트가 무언가를 지키고 있다는 것은 다른 얘기라는 걸, 최근 며칠 사이에 세 번이나 다시 확인했다. 세 사례 모두 CI는 초록불이었고 코드 리뷰도 별문제 없어 보였는데, 막상 뜯어보니 테스트가 지켜야 할 걸 하나도 지키지 않고 있었다. 셋 다 겉으로는 멀쩡한 단언문이었다는 점이 더 불편했다. 문법이 틀린 것도 아니고, 어설프게 짠 것도 아니었다. 각각 나름의 의도를 가지고 쓴 단언인데, 그 의도와 실제로 검사하는 대상이 미묘하게 어긋나 있었을 뿐이다.
세 사례를 관통하는 질문은 하나였다. "이 테스트가 실패하는 걸 본 적이 있는가." 없다면, 그건 통과한 게 아니라 애초에 실패할 방법이 없었던 것일 수 있다.
사례 1 — 검사하지 않는 게이트를 검사한다고 믿은 테스트
서명 토큰에 버전 접두 v1.를 붙이고, 다른 버전의 토큰은 거부하는 게이트를 추가했다. 서명은 맞는데 버전 접두가 다른 토큰을 넣으면 거부돼야 정상이다. 그런데 이 게이트를 지킨다고 믿었던 테스트를 열어보니 실제로는 서명이 일치하는지만 비교하고 있었다. 서명이 틀린 토큰은 애초에 서명 검증 단계에서 걸러지기 때문에, 버전 게이트 코드를 통째로 지워도 이 테스트는 여전히 통과했다. 테스트가 검증하고 있다고 믿었던 조건과 실제로 검증하는 조건이 다른 계층에 있었던 셈이다.
발견 경위가 특이했다. 이 게이트를 구현한 쪽이 "지시받은 대로 뮤테이션(구현 일부러 되돌리기)을 해봤는데 테스트가 살아남았다"고 정직하게 보고해서 드러났다. 만약 그 보고 없이 그냥 "테스트 통과했습니다"로 끝났다면 이 구멍은 그대로 묻혔을 것이다. 테스트를 서명은 올바르지만 버전만 다른 토큰을 거부하는지 직접 검증하는 형태로 바꾸고 나서야 뮤테이션에 반응하기 시작했다.
이 사례가 특히 곱씹게 되는 지점은, 검증 로직이 여러 단계로 겹쳐 있을 때 흔히 벌어지는 패턴이라는 것이다. 서명 검증과 버전 검증은 둘 다 "토큰을 거부한다"는 같은 결과로 이어지기 때문에, 테스트가 결과만 보고 통과 판정을 내리면 정작 어느 단계에서 거부됐는지는 확인하지 않고 넘어가기 쉽다. 결과가 같다고 원인까지 같은 걸 검증한 건 아니다.
사례 2 — expect.anything()이 null에 매치되지 않는다
이게 셋 중 제일 교활했다. 아래 형태의 부정 단언 두 건이 항상 통과하고 있었다.
expect(fn).not.toHaveBeenCalledWith(id, expect.anything(), x, y)
expect.anything()은 null과 undefined에는 매치되지 않는다는 게 핵심이다. 그런데 실제 호출에서 그 자리의 인자가 null이었다. 그러니 "이 인자 조합으로 호출된 적 없다"는 단언은 함수가 실제로 그 id로 몇 번을 호출되든 상관없이 항상 참이 됐다. 즉 이 단언은 코드가 무슨 짓을 해도 절대 실패할 수 없는 상태였다. 겉모습은 멀쩡한 부정 단언인데, 안을 열어보면 아무것도 검사하지 않는 문장이었다.
게다가 그중 하나는 원래는 실효가 있던 단언이었는데, 테스트를 다른 파일로 옮기는 리팩터링 과정에서 인자 위치나 값이 바뀌면서 조용히 무력화된 것이었다. 옮길 때 통과 여부만 확인하고 넘어갔기 때문에 아무도 눈치채지 못했다. 대응은 매처에 기대지 않고 실제 호출 인자를 직접 꺼내서 비교하는 것이다.
const ids = fn.mock.calls.map(c => c[0])
expect(ids).not.toContain(id)
이렇게 바꾸면 null이든 뭐든 실제 값 그대로 비교하기 때문에 매처의 사각지대에 숨을 자리가 없다.
이 사례가 더 무서운 이유는, 긍정 단언(toHaveBeenCalledWith)이었다면 같은 실수를 해도 대체로 금방 드러났을 거라는 점이다. 값이 매치되지 않으면 실패하니까. 문제는 부정 단언에서는 매처가 매치에 실패하는 방향이 오히려 테스트를 통과시키는 방향과 같다는 것이다. "매치 안 됨"과 "그런 호출이 없었음"이 결과적으로 같은 초록불을 만들어낸다.
사례 3 — 이벤트를 실제와 다른 곳에서 출발시킨 테스트
모달에 Esc 닫기 기능을 붙이고 테스트를 썼다. 테스트는 통과했다. 그런데 실제로 붙어 있던 핸들러는 모달 카드 엘리먼트의 onKeyDown이었고, 이 경우 포커스가 모달 밖으로 나가면 Esc가 먹지 않는 상태였다. 모달 안이라도 문단처럼 포커스를 받을 수 없는 영역을 클릭하면 같은 문제가 생겼다. 즉 사용자가 모달을 열고 아무 텍스트나 한 번 클릭하기만 해도 Esc가 죽어버리는 버그였다.
테스트가 이걸 못 잡은 이유는 단순했다. 테스트가 키 이벤트를 모달 엘리먼트에 직접 쏘고 있었기 때문이다. 실제 사용자의 키 이벤트는 항상 현재 포커스된 요소에서 시작해서 버블링된다. 테스트가 이벤트를 엘리먼트에 직접 발사하면 그 버블링 경로 자체가 실제 상황과 달라진다. 이벤트 출발점을 document.body로 바꾼 테스트를 새로 쓰자 곧바로 실패했고, 원인을 확인한 뒤 핸들러를 모달 엘리먼트가 아니라 document 리스너로 옮겨서 고쳤다.
이 사례를 겪고 나서 키보드 인터랙션 테스트를 쓸 때 스스로에게 묻는 질문이 하나 늘었다. "이 이벤트, 실제 사용자라면 어디서 출발했을까." 컴포넌트를 겨냥해서 이벤트를 쏘는 건 편하지만, 그 편함이 핸들러가 어디에 붙어 있는지를 검증에서 빼버리는 대가로 오는 경우가 있다.
어떻게 찾았나 — 뮤테이션 실측
세 사례 모두 뮤테이션 실측으로 확인했다. 새 테스트를 쓸 때마다 대상 구현의 해당 줄을 일부러 되돌려서 정말로 빨간불이 되는지 확인하고, 확인이 끝나면 원복한다. 구현하자마자 곧바로 초록불이 뜨는 것과, 구현을 되돌렸을 때 빨간불이 뜨는 것은 완전히 다른 얘기다. 후자를 확인하지 않은 테스트는 하중이 없다는 신호로 보는 게 맞다. 이걸 매번 습관으로 만들면 위와 같은 무의미한 단언들이 그 자리에서 드러난다.
작은 운영 팁 하나. 뮤테이션 실험 전에는 반드시 먼저 커밋하거나 백업해야 한다. 원복하려고 git checkout --를 썼다가 아직 커밋하지 않은 작업을 두 번이나 날린 적이 있다. 뮤테이션은 되돌릴 걸 전제로 하는 작업이라, 되돌리는 방법이 확실하지 않으면 시작하지 않는 게 낫다.
교훈
- 통과하는 테스트는 아무것도 증명하지 않는다. 실패할 수 있음을 보여야 비로소 무언가를 지킨다.
- 매처의 느슨한 자리(
expect.anything(),expect.any())는null/undefined와의 관계를 확인하고 써야 한다. 특히 부정 단언에서는 느슨한 매처가 단언 전체를 무력화한다. - 테스트가 이벤트나 입력의 출발점을 실제와 다르게 잡으면, 그 테스트는 존재하지만 하중이 없다.