러너가 못 찾는 테스트는 테스트가 아니다
어느 새벽, 브릿지 쪽 테스트 파일 하나를 직접 실행했다. 실패했다. 기대한 재시도 기록과 실제 기록이 달랐다. 이상해서 표준 테스트 명령으로 다시 돌렸다. 이번에는 실패가 아니라 더 이상한 문장이 나왔다. NO TESTS RAN. 테스트가 없다는 뜻이었다. 방금 실패한 테스트가 있었는데, 러너는 그 테스트를 못 보고 있었다.
처음엔 웃겼다. 테스트가 실패하면 고치면 된다. 그런데 테스트 러너가 그 테스트를 못 찾는 건 다른 문제다. 실패가 빨간불로 들어오지 않는다. 아무도 그 테스트가 깨졌다는 걸 모른다.
테스트 파일은 존재했다. 내용도 있었다. 직접 실행하면 assert가 터졌다. 하지만 표준 discover 규약에 맞지 않아 전체 테스트 스위트에 포함되지 않았다. 말하자면 소방벨은 있었는데 건물 배선에 연결되어 있지 않은 상태였다.
불이 나면 벨은 울린다. 단, 누가 직접 그 벨 앞에 가서 눌러볼 때만.
실패한 테스트보다 무서운 것
실패한 테스트는 좋은 신호다. 적어도 시스템이 문제를 보고 있다는 뜻이다. 빨간불은 불편하지만, 어디가 깨졌는지 알려준다.
러너가 못 찾는 테스트는 더 위험하다. 파일은 있어서 안심하게 만든다. “이 케이스 테스트 있어요”라고 말할 수 있다. 하지만 실제 CI나 전체 테스트 명령에서는 실행되지 않는다. 그래서 깨져도 아무도 모른다.
이번 파일이 그랬다. 직접 실행하면 stale thread 재시도 단정이 실패했다. 코드의 현재 동작은 queue 기반으로 바뀌었는데, 테스트는 예전 방식의 호출 순서를 기대하고 있었다. 테스트가 낡은 것이다.
문제는 그 낡은 테스트가 평소엔 안 돌았다는 점이다. 전체 테스트를 돌려도 발견되지 않았다. 그래서 코드가 바뀌는 동안 테스트는 조용히 과거에 남아 있었다. 어느 날 사람이 직접 파일을 실행해서야 드러났다.
낡은 테스트가 숨어 있는 것보다 나쁜 건 없다. 테스트가 있다는 착각을 주기 때문이다.
테스트 파일과 테스트 스위트는 다르다
테스트를 하나 만든다고 끝이 아니다. 그 테스트가 평소에 실행되는 길 위에 있어야 한다. 파일 이름, 클래스 구조, 함수 이름, 러너 규약 — 이 사소한 것들이 모두 맞아야 한다.
파이썬의 unittest는 특히 규약이 분명하다. 테스트 클래스가 unittest.TestCase를 상속하고, 메서드 이름이 test_로 시작해야 discover가 잡는다. 그냥 파일 안에 assert를 써두는 것만으로는 충분하지 않다. 직접 실행할 때만 의미가 있고, 전체 스위트에서는 빠질 수 있다.
이번 수정은 그래서 두 겹이었다. 첫 번째는 테스트의 기대값을 현재 코드 의미에 맞게 고치는 것. 두 번째는 그 테스트를 discover가 찾을 수 있게 클래스화하는 것.
첫 번째만 고치면 당장 직접 실행은 초록색이 된다. 하지만 다음에 또 러너 밖으로 밀려나면 같은 일이 반복된다. 두 번째가 더 근본이었다. 테스트가 CI 그물 안으로 들어와야 한다.
낡은 단정은 조용히 썩는다
이번 assert는 “재시도 시 어떤 인자가 어떤 순서로 호출되는가”를 보고 있었다. 예전 코드에서는 그 순서가 맞았다. 이후 브릿지 처리 방식이 바뀌면서 호출의 의미가 달라졌다. 기능 자체는 현재 방식이 맞는데, 테스트가 예전 내부 구현을 붙잡고 있었다.
이런 테스트는 두 가지 방식으로 썩는다.
하나는 너무 구체적인 내부 순서를 단정하는 것이다. 사용자가 보는 결과가 아니라 내부 함수 호출 순서를 박아두면, 구조가 바뀔 때 테스트가 깨진다. 깨지는 것 자체는 괜찮다. 문제는 그 테스트가 실행되지 않으면 깨진 줄도 모른다는 것이다.
다른 하나는 표준 러너 밖에 있는 것이다. 아무리 좋은 단정을 갖고 있어도 실행되지 않으면 방어력이 없다. 주머니 속 안전모 같은 것이다. 안전모는 있는데 머리에 쓰지 않았다.
이번엔 둘이 같이 있었다. 낡은 단정이 있었고, 그 단정이 러너 밖에 있었다.
후속 사고도 같은 뿌리였다
이 문제를 고친 뒤, 비슷한 계열의 더 큰 문제가 이어서 드러났다. 어떤 테스트가 모듈 전역의 subprocess.run을 가짜 함수로 바꿨다가 원복하지 않았다. 개별 실행은 통과했다. 하지만 전체 discover로 돌리면 뒤에 오는 테스트들이 오염된 subprocess를 물고 연쇄 실패했다.
이것도 같은 뿌리다. 테스트는 자기 방 안에서만 더럽혀야 한다. 끝나면 원복해야 한다. mock은 context 안에서 끝나야 하고, 환경변수도 patch 범위를 벗어나면 돌아와야 한다. 테스트 하나가 전역 상태를 바꾸고 나가면, 다음 테스트는 남의 쓰레기 위에서 시작한다.
개별 실행만 보면 이런 오염을 못 잡는다. 파일 하나만 돌리면 뒤에 피해자가 없기 때문이다. 전체 discover를 돌려야 보인다. 테스트는 단독으로 초록이어야 하고, 묶음으로도 초록이어야 한다.
이 교훈은 간단하지만 자주 잊는다. 개별 테스트 PASS는 필요조건이다. 충분조건이 아니다.
CI 그물 안에 들어온 뒤에야 테스트다
나는 테스트를 “파일”로 생각하던 습관이 있었다. 테스트 파일을 만들면 테스트를 만든 것처럼 느꼈다. 하지만 운영 관점에서 테스트는 파일이 아니라 실행 경로다. 평소에 돌고, 실패하면 멈추고, 누군가 볼 수 있어야 테스트다.
그래서 이제 테스트를 추가할 때 확인해야 할 질문이 하나 늘었다.
이 테스트는 내가 직접 파일명을 찍어 실행하지 않아도, 표준 명령에서 잡히는가?
잡히지 않으면 아직 끝난 게 아니다. 좋은 assert를 써도, mock을 잘 짜도, 그게 전체 스위트에 들어오지 않으면 방어선 밖에 있다. 문서에는 “테스트 있음”이라고 적을 수 있지만 실제로는 없다.
이번 수정 이후에는 직접 실행, 모듈 실행, discover 실행을 모두 확인했다. 같은 테스트가 세 경로에서 잡히는지 봤다. 귀찮지만 이 확인이 중요했다. 테스트가 존재한다는 말은 “내가 지금 실행할 수 있다”가 아니라 “평소 검증 레일이 자동으로 잡는다”여야 하기 때문이다.
배운 것
테스트 파일이 있다는 것과 테스트가 실행된다는 것은 다르다. 러너가 못 찾는 테스트는 없는 테스트와 거의 같다. 깨져도 아무도 모르면 방어선이 아니다.
내부 구현 순서를 과하게 단정하는 테스트는 코드 변화와 함께 낡는다. 낡아도 괜찮다. 실행만 된다면 빨간불로 알려준다. 문제는 낡은 테스트가 러너 밖에 숨어 있을 때다.
그리고 mock은 반드시 자기 범위 안에서 끝나야 한다. 전역 상태를 바꾸고 원복하지 않는 테스트는 뒤 테스트를 망친다. 개별 실행 PASS보다 전체 discover PASS가 더 중요할 때가 있다.
테스트는 파일이 아니라 습관적으로 실행되는 경로다. 그 경로에 올라오지 못한 테스트는, 아무리 좋은 말을 써놔도 아직 안전장치가 아니다.
— 강대종 / @ssamssae