100%의 커버리지를 가졌음에도 운영 환경이 다운된 이유
요약
코드 커버리지 100%임에도 운영 환경에서 오류가 발생한 경험을 바탕으로, 테스트의 한계점을 분석합니다. 라인 커버리지는 코드가 실행되었는지 여부만 측정할 뿐, 실제 비즈니스 로직이 올바르게 검증(Assert)되고 있는지를 알려주지 못한다는 점을 지적합니다.
핵심 포인트
- 커버리지 100%가 곧 완벽한 테스트를 의미하지는 않습니다.
- 라인 커버리지는 실행 여부만 측정하며, 실제 로직의 정확성(Assertion)은 별개입니다.
- 테스트 시 제외 목록(Exclusion list)을 조정하는 것은 속임수가 될 수 있습니다.
- 진정한 품질 보장은 '무엇이 실행되었는지'가 아닌 '무엇을 단언했는지'에 달려있습니다.
오늘날 제 프로젝트에 들어오는 코드의 상당 부분은 모델(Model)이 작성합니다. 이것은 실험이 아니라 우리가 일하는 방식입니다. 그리고 이는 이전에는 거의 스스로에게 던지지 않았던 질문을 불러일으켰습니다. 코드를 직접 작성했을 때는 어디에 구멍이 있는지 대략적으로 알 수 있었기 때문입니다: 내가 이것을 작성하지 않았다면, 이것이 제대로 작동하는지 어떻게 알 수 있을까?
뻔한 대답은 "커버리지(Coverage)를 측정한다"입니다. 저도 측정합니다. 그리고 한 번은 모든 것이 초록색(Pass)이었음에도 운영(Production) 환경에서 페이지가 다운된 적이 있습니다.
이 포스트는 그 모순에 관한 것이며, 숫자가 의미를 갖게 하거나 혹은 아무런 의미도 없게 만드는 결정들에 관한 것입니다.
첫째, 무엇을 측정하는가
특별할 것 없는 방식입니다: flutter test --coverage를 실행하여 lcov.info를 생성한 다음, 백분율을 산출하기 전에 lcov --remove로 필터링합니다.
lcov --remove coverage/lcov.info \
'**/*.freezed.dart' \
'**/*.g.dart' \
...
제가 제외하는 항목들과 그 이유는 다음과 같습니다:
생성된 파일들(freezed, json_serializable, 의존성 주입(DI) 설정 등)은 제가 작성한 것이 아닙니다. 그것들을 테스트하는 것은 생성기(Generator)를 테스트하는 것입니다. 만약 freezed가 고장 났다면, 그것은 제 테스트의 책임이 아닙니다.
상수(Constants)와 테마(Theme)는 분기(Branch)가 없습니다. 동작을 다르게 만드는 입력값이 없습니다. 거기서의 테스트는 단지 상수가 정해진 값인지 확인하는 것뿐입니다.
DI(의존성 주입)와 라우팅(Routing)은 배선(Wiring) 작업입니다. 컨테이너가 의존성을 해결하는지 확인하는 것은 프레임워크를 테스트하는 것입니다.
규칙을 요약하자면, 잘못된 결정을 내릴 수 없는 것들을 제외하는 것입니다. 그 외의 모든 것은 포함됩니다.
그리고 여기서 다른 사람이 말하기 전에 불편한 사실을 하나 말해야겠습니다: 제외 목록(Exclusion list)은 속임수를 쓸 수 있는 곳입니다. 이러한 패턴들을 조정함으로써 제가 원하는 어떤 퍼센트라도 도달할 수 있습니다. 그래서 저는 숫자가 마음에 들지 않는다고 해서 제외 목록을 건드리지 않습니다. 만약 제가 무언가를 제외한 이유를 정당화해야 한다면, 그 대답이 "목표 수치에 도달하지 못해서"가 되어서는 안 됩니다.
모든 것을 실행하지만 아무것도 검증하지 않는 테스트
이것이 경험 있는 사람들이 커버리지를 불신하게 만드는 오해이며, 그들이 불신하는 데에는 타당한 이유가 있습니다.
lcov는 어떤 라인이 실행되었는지를 측정합니다. 무언가를 검증했는지는 측정하지 않습니다. 이 둘은 서로 다른 것이며, 테스트를 생성하는 모델들은 이 둘을 항상 혼동합니다.
test('할인 적용', () {
final result = calculator.applyDiscount(100, 0.2);
expect(result, isA<double>());
...
이 테스트는 applyDiscount의 모든 라인을 실행합니다. 커버리지(Coverage): 100%. 실제 검증(Verifications): 0개. 만약 공식이 거꾸로 되어 precio * (1 - descuento) 대신 precio * descuento라고 되어 있더라도, 테스트는 똑같이 통과(Green)될 것입니다.
test('20% 할인 적용', () {
expect(calculator.applyDiscount(100, 0.2), 80.0);
});
...
라인 커버리지는 동일합니다. 이 차이는 그 어떤 도구로도 측정할 수 없습니다. 오직 하나의 질문으로만 측정할 수 있습니다: "테스트가 무언가를 단언(Assert)하고 있는가, 아니면 단순히 실행만 하고 있는가?"
모델들은 기본적으로 첫 번째 방식(단순 실행)으로 코드를 작성합니다. 그것이 모델들이 가장 잘하는 일이기 때문입니다: 코드를 훑고, 통과(Green) 상태를 만들고, 숫자를 올리는 것 말입니다. 생성된 테스트를 검토할 때 저는 퍼센트가 올라갔는지를 보지 않습니다. 대신 제가 의도적으로 함수를 망가뜨렸을 때 어떤 일이 일어나는지를 봅니다. 만약 테스트가 여전히 통과된다면, 그 테스트는 존재하지 않는 것이나 다름없습니다.
프레젠테이션 레이어(Presentation Layer)를 제외한 것, 그것은 게으름 때문이 아니다
이것은 제가 내린 결정 중 가장 힘들었지만, 동시에 가장 강력하게 옹호하는 결정입니다.
저도 처음에는 남들처럼 위젯 테스트(Widget tests)를 작성하며 시작했습니다. 하지만 두 가지 이유 때문에 멈추게 되었습니다.
첫 번째는 어리석어 보일 수 있지만 실제적인 문제입니다: expect가 위젯을 찾지 못하는 일이 발생했습니다. 트리(Tree) 구조가 어떻게 형성되었느냐에 따라 어떤 때는 되고 어떤 때는 안 되었습니다. 결국 동작을 검증하는 대신 파인더(Finders)와 씨름하며 시간을 허비하게 되었습니다. Flutter에서 위젯 테스트를 작성해 본 사람이라면 누구나 제 말이 무슨 뜻인지 이해할 것입니다.
두 번째가 중요한 이유입니다. 앱에는 사용자의 역할(Role)에 따라 나타나거나 나타나지 않는 옵션들이 있으며, 그 케이스는 꽤 많습니다. 하지만 그 결정은 위젯에 존재하는 것이 아니라 BLoC에 존재합니다. 위젯은 단지 상태(State)가 지시하는 대로 그릴 뿐입니다. 따라서 위젯을 통해 "사용자 X가 옵션 Y를 본다"를 테스트한다면, 저는 이미 BLoC에서 검증한 동일한 규칙을 더 느리고, 더 취약하며, 누군가 Padding 하나만 바꿔도 깨져버리는 통로를 통해 다시 검증하고 있는 셈입니다.
UI를 테스트하지 않는 것이 아닙니다. 다만 똑같은 것을 두 번 테스트하지 않을 뿐이며, 테스트 비용이 저렴하고 안정적인 곳을 선택하여 테스트할 뿐입니다.
(제가 놓치고 있는 부분, 그리고 솔직한 포스트라면 어디까지 도달하지 못했는지도 말해야 하기에 덧붙이자면: 안정적인 부분에 대한 골든 테스트 (golden tests)가 부족합니다. 할 일 목록에는 있습니다. 아직 하지는 않았습니다.)
레거시 (Legacy): 과거를 쫓지 않는다
제로 베이스에서 시작하는 프로젝트에서는 모든 로직을 커버하는 것을 목표로 합니다. 하지만 이미 떠난 사람들이 작성한, 테스트가 없는 기존의 레거시 코드 (legacy code)도 유지보수하고 있습니다.
그 부분에 대해서는 과거를 향해 100%를 추구하지 않습니다. 만질 일도 없는 코드를 위해 몇 달 동안 테스트를 작성하는 것은 비효율적이며, 더 나쁜 것은 테스트가 동작을 보호하는 대신 현재의 구현 (implementation)을 고착화시켜 버리는 것입니다.
제가 하는 방식은 더 단순합니다. 오래된 코드는 그대로 두되, 그 위에 추가되는 모든 새로운 기능은 테스트와 함께 들어갑니다. 해당 모듈을 건드릴 때마다 기준선은 높아집니다. 과거를 커버하기 위해 배포를 멈추는 것이 아니라, 오염(sangría)이 번지는 것을 막는 것입니다.
이는 "이 프로젝트는 100%를 갖추고 있다"와 "이 프로젝트는 다시는 커버리지를 잃지 않는다"의 차이입니다. 후자는 세상을 멈추지 않고도 달성 가능합니다.
추락
그리고 바로 이 지점이 숫자가 제 얼굴을 속인 부분입니다.
제 테스트 스위트 (test suite)는 모크 (mocks) 위에서 실행됩니다. 모든 데이터 계층이 모킹 (mocked)되어 있기 때문에, 테스트는 서버와 통신하는 것이 아니라 제가 마지막으로 그 모크를 작성했을 때 서버가 말했던 내용을 얼려둔 버전과 통신합니다.
저희는 배송 완료된 주문에 대해 포인트를 지급하는 시스템이 있습니다. 어느 날, 백엔드 (backend)의 오류로 인해 배송 완료된 주문의 포인트가 로드되지 않았고 해당 필드가 null로 들어왔습니다. 이전에는 단 한 번도 null이 들어온 적이 없었습니다. 앱은 이를 견디지 못했고, 웹도 마찬가지였으며, 페이지는 에러를 뱉었습니다.
제 모든 테스트는 초록불이었습니다. 전부 다요.
그 이유는 너무나 단순해서 거의 굴욕적일 정도입니다: 제 모크는 여전히 옛날 세상을 반환하고 있었습니다. 저는 이미 존재하지 않는 현실에 대해 완전한 커버리지를 가지고 있었던 것입니다. 테스트는 결코 null을 본 적이 없었습니다. 왜냐하면 제가 null을 반환하는 모크를 작성한 적이 없기 때문이며, 제 머릿속에서는 해당 필드가 항상 값을 가지고 온다고 생각했기에 작성하지 않았기 때문입니다.
이것이 커버리지가 아무 의미 없다고 말하는 누구에게나 제가 내놓는 답변입니다. 당신 말이 맞습니다. 그리고 여기 제 테스트 스위트(test suite)가 주는 증거가 있습니다. 100%라는 것은 당신이 직접 작성한 가정(assumptions)에 따라 코드 라인을 실행했다는 것을 의미할 뿐입니다. 만약 당신의 가정이 낡았다면, 당신은 당신의 상상력이 얼마나 잘 테스트되고 있는지를 측정하고 있는 것입니다.
교훈은 "모크(mocks)는 나쁘다"가 아니었습니다. 저는 모크가 필요합니다. 교훈은 라인 커버리지(line coverage)와 케이스 커버리지(case coverage)는 같지 않으며, null 케이스가 제가 가장 자주 놓치는 부분이었다는 점입니다. 그 이후로 null 값과 계약 변경(contract changes)은 제 테스트에서 사후에 고려되는 사항이 아니라, 일급 시민(first-class citizens)으로서 다뤄지고 있습니다.
test('백엔드의 null 포인트를 처리함', () {
expect(() => calculator.applyDiscount(null, 0.2), throwsArgumentError);
});
솔직히 말해서, 저는 아직 이에 대한 완전한 해답을 가지고 있지 않습니다. 실제 응답에 대한 계약 테스트(contract test)를 수행하는 것이 옳겠지만, 현재로서는 구축되어 있지 않습니다. 지금 제가 하는 방식은 백엔드가 절대 그렇지 않다고 장담하더라도, 어떤 필드든 null로 들어올 수 있다고 가정하는 것입니다.
제가 실제로 말하고자 하는 것
커버리지는 당신이 어디를 보지 않았는지를 알려줍니다. 당신이 본 것이 올바른지는 알려주지 않습니다.
모델은 하루 종일 실행된 라인을 공짜로 제공합니다. 하지만 무엇이 테스트할 가치가 있는지, 무엇이 진실을 주장하는지, 무엇을 커버할 가치가 없는지, 그리고 언제 그 수치가 당신을 속이고 있는지를 결정해 주지는 않습니다.
그것은 여전히 당신의 몫입니다. 적어도 지금은 말이죠.
당신은 커버리지에서 무엇을 제외하며, 그 이유는 무엇인가요? 특히 누군가 모크(mocks)에 맞서 API 계약(API contracts) 문제를 잘 해결했다면 정말 궁금합니다. 저는 아직 해결하지 못했거든요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기