본문으로 건너뛰기

신뢰할 수 있는 테스트

좋은 테스트는 다음 세 가지 특성을 만족해야 합니다:

  • 신뢰성: 신뢰할 수 있는 테스트란 버그가 없고 올바른 대상을 테스트함을 의미합니다.
  • 유지 보수성: 코드가 조금만 바뀌어도 테스트를 계속 수정해야 한다면 결국 유지 보수에 지친 개발자들이 손을 놓습니다.
  • 가독성: 가독성은 테스트를 읽을 수 있는 것 외에도 테스트가 잘못된 경우 문제를 파악할 수 있는 능력을 의미합니다. 가독성이 없으면 신뢰성이나 유지 보수성도 큰 힘을 발휘하지 못합니다.

1. 테스트를 신뢰할 수 있는지 판단하는 방법

일반적으로 다음 상황에서는 테스트를 신뢰하지 않습니다.

  • 테스트는 실패했지만 신경 쓰지 않는 경우, 거짓 양성
  • 테스트가 가끔 통과하거나 현재 작업과 관련 없다고 생각하거나 테스트에 버그가 있다고 느껴서 테스트 결과를 무시
  • 테스트가 통과했지만 의심스러운 경우, 거짓 음성
  • ‘만약에 대비하여’ 직접 디버깅하거나 프로그램을 테스트할 필요을 느끼는 경우.

반대로 다음 상황에서는 테스트를 신뢰합니다.

  • 테스트가 실패했을 때, 코드의 무엇인가가 잘못되었을까 봐 진심으로 걱정. 혹은 테스트가 틀렸다고 생각.
  • 테스트가 통과했을 때, 따로 수동으로 테스트하거나 디버깅할 필요가 없다고 느끼는 경우

2. 테스트가 실패하는 이유

단위 테스트를 포함한 모든 종류의 테스트 실패는 합당한 이유가 있어야 납득할 수 있습니다. 이 합당한 이유란 바로 실제 버그가 프로덕션 코드에서 발견된 경우를 의미합니다. 하지만 테스트는 다양한 이유로 실패할 수 있습니다.

  • 프로덕션 코드에서 실제 버그가 발견된 경우
  • 테스트가 거짓 실패를 일으키는 경우
  • 기능 변경으로 테스트가 최신 상태가 아닌 경우
  • 테스트가 다른 테스트와 충돌하는 경우
  • 테스트가 불안정한 경우

첫 번째 이유를 제외하면 나머지 이유 모두가 현재 단계에서는 테스트를 신뢰할 수 없음을 의미합니다.

프로덕션 코드에서 실제 버그가 발견된 경우

테스트가 실패하는 첫 번째 이유는 프로덕션 코드에 버그가 있을 때 입니다. 이 이유 때문에 테스트를 작성하는 것입니다.

테스트가 거짓 실패를 일으키는 경우

테스트 자체에 버그가 있으면 거짓 실패, 즉 프로덕션 코드에는 문제가 없지만 테스트가 실패하는 상황이 발생할 수 있습니다.

테스트에 버그가 있는지 찾아내려면 테스트 코드를 디버깅해야합니다. 다음 항목에 해당하면 거짓 실패를 일으킨다고 볼 수 있습니다.

  • 잘못된 항목이나 잘못된 종료점을 검증하는 경우
  • 잘못된 값을 진입점에 전달하는 경우
  • 진입점을 잘못 호출하는 경우

테스트에 버그를 발견했다면, 버그를 수정하고 테스트를 다시 실행하여 통과하는지만 확인하면 됩니다. 테스트가 통과했다면, 프로덕션 코드에 일부러 버그를 넣어봅니다. 그 다음 테스트를 다시 실행하여 테스트가 실패하는지 확인합니다.

테스트 주도 개발(TDD) 방식으로 코드를 작성하면 코드의 어디가 잘못되었는지 쉽게 찾을 수 있어 버그를 미리 막을 수 있습니다. 테스트 버그를 줄일 수 있는 또 다른 방법은 테스트 내부의 복잡한 로직을 제거하는 것입니다.

기능 변경으로 테스트가 최신 상태가 아닌 경우

기능이 변경되면 테스트가 현재 기능과 맞지 않아 실패할 수 있습니다. 이럴 때에는 두 가지 선택지가 있습니다.

  • 테스트를 새로운 기능에 맞게 수정한다.
  • 새로운 기능을 대상으로 새 테스트를 만들고 기존 테스트는 삭제한다.

테스트가 다른 테스트와 충돌하는 경우

서로 다른 두 테스트가 있습니다. 실행할 때마다 하나는 실패하고 다른 하나는 성공합니다. 이 두 테스트는 결코 동시에 통과할 수 없다고 가정합니다.

예를 들어 첫 번째 테스트는 인수 두개로 함수를 호출하면 결과가 ‘3’이 되어야하는 반면, 두 번째 테스트는 같은 함수 호출이 결과를 ‘4’를 반환해야 합니다. 이런 경우 두 테스트가 서로 상충되는 기대치를 갖고 있기 때문에 동시에 성공할 수 없습니다.

문제의 근본적인 원인은 두 테스트 중 하나가 더 이상 쓸모없어졌다는 것입니다. 그렇다면 어떤 테스트를 없애야 할까요? 이 질문은 제품의 기능과 요구사항을 결정하는 사람, 즉 프로덕트 오너에게 물어보아야 합니다.

이 상황은 테스트와 기능이 발전하는 과정에서 자연스러운 현상이며 이를 굳이 피하려고 하지 않아도 괜찮습니다.

테스트가 불안정한 경우

테스트가 불규칙하게 실패할 때가 있습니다. 이러한 테스트를 ‘불안정한 테스트’라고 하며, 5절에서 자세히 보겠습니다.

3. 단위 테스트에서 불필요한 로직 제거

테스트에 로직을 많이 넣을수록 테스트에 버그가 생길 확률이 기하급수적으로 증가합니다. 간단하게 끝날 테스트가 동적인 로직을 갖거나, 난수를 생성하거나, 스레드를 만들거나, 파일을 쓰는 등 행위를 하면서 복잡해지게 됩니다.

처음에는 작은 코드 조각에서 시작됩니다. 개발자들은 테스트에 반복문을 추가하거나 난수를 입력값으로 주면 더 많은 버그를 찾을 수 있을 것이라 생각합니다. 이렇게 하면 테스트 코드 내에서 실제로 더 많은 버그를 찾을 수 있습니다. 하지만 실제로는 테스트 자체를 복잡하게 만들어 추후에 버그를 유발하고 유지 보수를 어렵게 합니다.

다음 내용이 단위 테스트에 포함되어 있다면 불필요한 로직이 포함된 것이므로 줄이거나 제거해야 합니다.

  • switch, if else 문
  • foreach, for, while 루프
  • 문자열 연결 (+ 기호) 등
  • try/catch 블록

위와 같은 로직이 있다면, 테스트 대상 함수에서 사용하는 로직이 테스트 코드에서도 그대로 사용되고 있을 확률이 높습니다. 즉, 이 함수 로직에 버그가 있다면 테스트에서도 동일한 버그가 발생합니다. 결과적으로 테스트는 버그를 잡아내지 못하고, 오히려 잘못된 결과를 기대하게 됩니다. 이는 더 복잡한 로직에서도 동일한 문제가 발생할 수 있으며, 테스트 신뢰성을 크게 떨어뜨립니다.

기댓값(expect)은 하드코딩으로 만드는 것이 좋습니다. 테스트의 입력 값을 간단하게 만들어 기댓값을 하드코딩으로 작성하기 쉽도록 하는 것입니다. 하지만 이는 주로 단위 테스트에 해당하는 이야기입니다. 더 높은 수준의 테스트에서는 직접 기댓값을 만드는 것이 어려울 수 있습니다.

4. 테스트가 통과하더라도 끝이 아니다

테스트가 통과하더라도 그 테스트를 믿지 못하는 몇 가지 이유가 있습니다.

  • 검증 부분이 없는 경우
  • 테스트를 이해할 수 없는 경우
  • 단위 테스트가 불안정한 통합 테스트와 섞여 있는 경우

검증 부분이 없는 경우

테스트에 검증(assert) 부분이 없으면 함수 호출 내 검증 로직이 숨어 있을 수 있습니다. 이럴 때에는 예외가 발생하지 않는지 확인하는 테스트를 작성하기도 합니다. 이러한 테스트도 어느 정도 가치는 있지만, 테스트 이름에 ‘예외 없음”같은 용어를 포함하여 이를 명확히 해야합니다. jest는 예외가 발생하지 않음을 확인하기 위한 not.toThrow 와 같은 api를 활용할 수 있도록 제공합니다. 이러한 테스트가 있다면 더 이상 수를 늘리지 않는 것이 좋습니다.

테스트를 이해할 수 없는 경우

이해할 수 없는 테스트는 큰 문제입니다. 어떤 유형들이 있는지 간단하게 살펴보고 자세한 내용은 9장에서 살펴보겠습니다.

  • 이름이 적절하지 않은 테스트
  • 코드가 너무 길거나 복잡한 테스트
  • 변수 이름이 헷갈리게 되어 있는 테스트
  • 숨어 있는 로직이나 이해하기 어려운 가정을 포함한 테스트
  • 결과가 불분명한 테스트(실패도 아니고 통과도 아닌 경우)
  • 충분한 정보를 제공하지 않는 테스트 메시지

불안정한 통합 테스트와 섞여 있는 경우

통합테스트는 단위 테스트보다 의존성이 많아 불안정할 가능성이 더 높습니다. 이러한 테스트가 같은 폴더에 있거나 하나의 테스트 실행 명령어로 함께 실행된다면 이를 의심해 보아야 합니다.

불안정한 테스트와 안정적인 테스트가 섞여 있으면 문제를 무시하고 다른 작업에 집중하기 좋은 핑계 거리가 됩니다. 이를 방지하기위해 통합테스트와 단위테스트를 분리하여 두 테스트가 섞이지 않도록 안정적인 테스트 영역(safe green zone)을 만드는 것이 중요합니다. 안정적인 테스트 영역에는 빠르고 신뢰할 수 있는 테스트만 포함되어야 합니다.

테스트가 여러 가지를 한꺼번에 검증하는 경우

테스트에서 여러 가지를 테스트하면 문제가 되는 첫 번째 이유는 테스트 이름이 모호하기 때문입니다. 게다가 하나의 테스트에서 여러 가지를 한꺼번에 확인하는 것은 복잡성만 늘릴 뿐 별로 도움이 되지 않습니다. 각 검증은 별도의 테스트로 나누어 무엇이 실제로 실패했는지 명확히 확인하는 것이 좋습니다.

테스트가 자주 변경되는 경우

현재 날짜와 시간을 사용하는 테스트는 매번 실행할 때마다 다른 테스트가 된다고 할 수 있습니다. 난수, 컴퓨터 이름, 외부 환경 변수 값을 가져오는 테스트도 마찬가지 입니다. 이러한 테스트는 결과가 일관되지 않을 가능성이 크기 때문에 불안정할 수 있습니다. 3절에서도 언급했듯 동적으로 만든 값을 테스트에서 사용하는 것은 문제가 생길 여지가 많습니다. 왜냐하면 입력 값을 미리 알 수 없으므로 예상 기댓값도 함께 계산해야하며, 이는 프로덕션 코드 로직의 일부일 수 있기 때문입니다.

5. 불안정한 테스트 다루기

불안정한 테스트란 코드에 아무런 변화가 없는데도 일관성 없는 결과를 반환하는 테스트를 의미합니다.

가장 낮은 수준에 해당하는 단위 테스트에서는 테스트가 모든 의존성을 완전히 제어할 수 있어 변동 요소가 없습니다. 이는 의존성을 가짜로 만들거나 메모리에서만 실행되기 때문입니다.

테스트 수준이 올라갈수록 스텁과 모의 객체를 덜 사용하고 데이터베이스, 네트워크, 환경 설정 등 실제 의존성을 더 많이 사용합니다. 그 결과 제어할 수 없는 변동 요소가 많아집니다.

가장 낮은 수준의 테스트는 불안정한 요소가 없어야 하므로 테스트가 불안정해질 이유가 없다고 생각할 수 있습니다. 이것은 이론적으로는 맞지만 실제로는 그렇지 않습니다. 낮은 수준의 테스트에서도 현재 날짜와 시간, 컴퓨터 이름 네트워크, 파일 시스템 등을 사용하면 변동 요소가 생겨 테스트가 불안정해질 수 있습니다.

불안정한 테스트를 발견했을 때 할 수 있는 일

불안정한 테스트는 조직에 비용을 발생시킬 수 있으므로 제거하는 것을 장기 목표로 삼아야 합니다.

  • 문제 정의하기 : 테스트 케이스를 열 번 실행한 후 결과가 일관되지 않은 테스트를 모두 새어 봅니다.
  • 불안정하다고 판단된 테스트는 별도의 카테고리나 폴더에 따로 모아 실행할 수 있도록 합니다. 주요 배포 빌드에서 불안정한 테스트를 제외하여 불필요한 잡음을 줄이고, 이 테스트들을 임시로 별도의 파이프라인으로 분리하는 것이 좋습니다.
  • 각 불안정한 테스트를 하나씩 검토하며 수정, 리팩토링, 삭제 과정을 거치도록 합니다.

Wrap Up

신뢰할 수 있는 테스트는 개발 과정에서 중요한 역할을 하며, 테스트의 신뢰성, 유지 보수성, 가독성을 높이는 것이 핵심입니다. 이를 위해 테스트의 불안정성을 줄이고, 명확하고 간단한 테스트를 작성하는 것이 중요합니다.

Summary

  1. 신뢰할 수 있는 테스트는 거짓 양성이나 거짓 음성을 피하고, 실패 시 원인을 명확히 파악할 수 있어야 합니다.
  2. 테스트 실패의 원인은 프로덕션 코드의 버그, 테스트 자체의 버그, 기능 변경, 테스트 간 충돌, 또는 불안정성일 수 있습니다.
  3. 테스트 코드에 불필요한 로직을 줄이고, 기댓값을 하드코딩하여 단순화하는 것이 중요합니다.
  4. 통합 테스트와 단위 테스트를 분리하여 안정적인 테스트 영역을 유지해야 합니다.
  5. 불안정한 테스트는 조직에 비용을 초래하므로 별도로 관리하고, 수정, 리팩토링, 삭제를 통해 제거해야 합니다.
  6. 테스트는 단순히 통과하는 것에 그치지 않고, 신뢰성과 유지 보수성을 지속적으로 개선해야 합니다.

Reference