1장, 단위 테스트의 목표

2022.03.03

단위 테스트는 단순히 테스트를 작성하는 것보다 더 큰 범주다. 단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야 하며, 테스트에 드는 노력을 가능한 한 줄이고 그에 따르는 이득을 최대화해야 한다.

단위 테스트 기술 간의 차이로, 일부는 훌륭한 결과를 만들고 소프트웨어 품질을 지키는데 도움이 되고, 일부는 자주 고장나며 유지 보수가 많이 필요하는 경우도 있다.

1.1 단위 테스트 현황

  • 단위 테스트를 적용해야 하는지는 더 이상 논쟁거리가 아니다.
  • 그냥 쓰고 버리는 프로젝트가 아니면, 단위 테스트는 늘 적용해야 한다.
  • 많은 프로젝트에 자동화된 테스트가 있고, 테스트도 많지만 개발자들이 원하는 결과를 얻지 못하는 경우가 많다.
  • 좋은 테스트와 좋지 않은 테스트의 차이는 취향이나 개인적인 선호도의 문제가 아니라 중대한 프로젝트의 성패를 가르는 문제다.
  • 테스트 작성에 그치지 않고 노력 대비 최대의 이익을 끌어내는 방식으로 단위 테스트를 수행하도록 해야 한다.

1.2 단위 테스트의 목표

  • 단위 테스트와 코드 설계의 관계
    • 코드를 단위 테스트하기 어렵다면 코드 개선이 반드시 필요하다는 것을 의미한다.
    • 보통 강결합에서 저품질이 나타나는데, 강결합은 제품 코드가 충분히 분리되지 않아서 따로 테스트하기 어려움을 뜻한다.
    • 코드베이스를 쉽게 단위 테스트할 수 있다고 해도 반드시 코드 품질이 좋은 것을 의미하지 않는다.
    • 낮은 결합도를 보여도 프로젝트는 '대참사'가 될 수 있다.
  • 단위 테스트의 목표는 소프트웨어의 지속 가능한 성장을 가능하게 하는 것이다.
  • 테스트를 통해 소프트웨어 엔트로피(무질서도)를 낮출 수 있다.
    • 테스트는 안전망 역할을 하며 대부분의 회귀에 대한 보험을 제공하는 도구라 할 수 있다.
    • 새로운 기능, 요구 사항에 맞게 리팩터링한 후에도 기존 기능이 잘 작동하는지 확인하는데 도움이 된다.
  • 코드베이스를 지속적으로 검증하는 테스트 없이는 소프트웨어 개발이 쉽게 확장되지 않는다.

1.2.1 좋은 테스트와 좋지 않은 테스트를 가르는 요인

  • 모든 테스트를 작성할 필요는 없다.
    • 일부 테스트는 아주 중요하고 품질에 많은 기여를 한다.
    • 그 밖의 일부는 잘못된 경고가 발생하고, 회귀 오류를 알아내는데 도움이 되지 않고 유지보수가 어렵고 느리다.
  • 테스트의 가치와 유지 비용을 모두 고려해야 한다.
    • 기반 코드를 리팩터링할 때 테스트도 리팩터링하라.
    • 각 코드 변경시 테스트를 실행하라.
    • 테스트가 잘못된 경고를 발생시킬 경우 처리하라.
    • 기반 코드가 어떻게 동작하고 이해하려고 할 때는 테스트를 읽는 데 시간을 투자하라.
  • 제품 코드 대 테스트 코드
    • 코드는 자산이 아니라 책임이다.
    • 코드가 더 많아질수록, 소프트웨어 내의 잠재적인 버그에 노출되는 표면적이 넓어지고 유지비가 증가한다.
    • 가능한 한 적은 코드로 문제를 해결하는 것이 좋다.
    • 테스트도 역시 코드다. 애플리케이션의 정확성을 보장하는 것을 목표로 하는 코드베이스의 일부로 봐야 한다.
    • 다른 코드와 마찬가지로 단위 테스트도 버그에 취약하고 유지보수가 필요하다.

1.3 테스트 스위트 품질 측정을 위한 커버리지 지표

  • 커버리지 지표는 테스트 스위트가 소스 코드를 얼마나 실행하는지를 백분율로 나타낸다.
  • 커버리지 지표는 중요한 피드백을 주더라도 테스트 스위트 품질을 효과적으로 측정하는 데 사용될 수 없다.

1.3.1 코드 커버리지 지표에 대한 이해

  • 가장 많이 사용되는 커버리지 지표로 코드 커버리지(code coverage)가 있으며 테스트 커버리지(test coverage)로도 알려져 있다.
  • 커버리지 지표는 테스트 스위트가 소스 코드를 얼마나 실행하는지를 백분율로 나타낸다.

코드 커버리지(테스트 커버리지) = 제품 코드 라인 수 / 전체 라인 수

코드 커버리지 지표는 테스트 스위트가 실행한 코드 라인 수와 제품 코드 베이스의 전체 라인 수의 비율로 계산한다.

public static bool IsStringLong(string input)
{
  if (input.length > 5)
    return true;
  return false;
}

public void Test()
{
  bool result = IsStringLong("abc");
  Assert.Equal(false, result);
}

위와 같은 경우 코드 커버리지를 쉽게 계산할 수 있다.
테스트가 실행하는 라인 수는 4이다. 테스트는 true를 반환하는 구문을 제외한 모든 라인을 통과한다.
따라서 코드 커버리지는 4/5 = 0.8 = 80%이다.

public static bool IsStringLong(string input)
{
  return input.Length > 5;
}

불필요한 if문을 한 줄로 처리하도록 리팩터링하면, 테스트는 코드 세 줄을 모두 점검하기 때문에 코드 커버리지는 100%가 된다.
이 예제는 커버리지 숫자에 대해 얼마나 쉽게 장난칠 수 있는지 보여준다.
코드가 작을수록 커버리지 지표는 더 좋아지는데, 코드를 더 작게 해도 테스트 스위트의 가치나 기반 코드베이스의 유지 보수성이 변경되지 않는다. (변경해서도 안 된다.)

1.3.2 분기 커버리지 지표에 대한 이해

  • 또 다른 커버리지 지표는 분기 커버리지(branch coverage)다.
  • 분기 커버리지는 코드 커버리지의 단점을 극복하는 데 도움이 되므로 코드 커버리지보다 더 정확한 결과를 제공한다.

분기 커버리지 = 통과 분기 / 전체 분기 수

분기 지표는 테스트 스위트가 수행하는 코드 분기 수와 제품 코드베이스의 전체 분기 수에 대한 비율로 계산한다.

앞선 예제에서는 하나의 분기문만 테스트가 되므로 이런 경우에는 분기 커버리지의 지표는 50%라고 할 수 있다.

1.3.3 커버리지 지표에 관한 문제점

분기 커버리지로 코드 커버리지보다 더 나은 결과를 얻을 수 있지만, 테스트 스위트의 품질을 결정하는 데 어떤 커비리지 지표도 의존할 수 없는 이유는 다음과 같다.

  • 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장할 수 없다.
  • 외부 라이브러리의 코드 경로를 고려할 수 있는 커버리지 지표는 없다.

가능한 모든 결과를 검증한다고 보장할 수 없음

단지 코드 경로를 통과하는 것이 아니라 실제로 테스트하려면, 단위 테스트에는 반드시 적절한 검증이 있어야 한다.

public static bool WasLastStringLong { get; private set; }

public static bool IsStringLong(string input)
{
  bool result = input.Length > 5;
  WasLastStringLong = result;   // 첫 번째 결과
  return result;                // 두 번째 결과
}

public void Test()
{
  bool result = IsStringLong("abc");
  Assert.Equal(false, result);  // 두 번째 결과만 검증
}

위의 예제에서는 값을 반환하는 명시적인 결과와 속성에 새로운 값을 쓰는 암묵적인 결과가 있다.
암묵적인 결과를 검증하지 않더라도 커버리지 지표는 100% 코드 커버리지와 50% 분기 커버리지의 동일한 결과를 보여준다. 보다시피 커버리지 지표는 기반 코드를 테스트했다고 보장할 수 없으며 일부 실행된 것만 보장한다.

테스트 대상 코드에 대해 모든 결과를 철저히 검증했다고 하더라도, 분기 커버리지 지표와 함께 신뢰할 수 있는 구조라고 할 수 없다.
또한, 테스트 스위트 품질을 결정하는 데 사용 할 수 있지도 않다.

외부 라이브러리의 코드 경로를 고려할 수 없음

두 번쨰 문제는 모든 커버리지 지표가 테스트 대상 시스템이 메서드를 호출할 때 외부 라이브러리가 통과하는 코드 경로를 고려하지 않는다는 것이다.

public static int Parse(string input)
{
  return int.Parse(input);
}

public void Test()
{
  int result = Parse("5");
  Assert.Equal(5, result);
}

위의 예제는 분기 커버리지 지표는 100%로 표시되며, 테스트는 메서드 결과의 모든 구성 요소를 검증한다.
하지만 이 테스트는 완벽하지 않다. .NET 프레임워크의 int.Parse 메서드가 수행하는 코드 경로는 고려하지 않는다. ( 입력 매개 변수에 따라 다른 결과로 이어질 수 있다. null, 빈 문자열 등 )

이는 커버리지 지표가 외부 라이브러리의 코드 경로를 고려해야 한다는 것이 아니라, 해당 지표로는 단위 테스트가 얼마나 좋은지 나쁜지를 판단할 수 없다는 것을 보여준다.
또한, 테스트가 철저한지, 충분한지 알 수도 없다.

1.3.4 특정 커버리지 숫자를 목표로 하기

  • 커버리지 지표를 보는 가장 좋은 방법은 지표 그 자체로 보는 것이며, 목표로 여겨서는 안 된다.
  • 시스템의 핵심 부분은 커버리지를 높게 두는 것이 좋다. 하지만 이 높은 수준을 요구 사항으로 삼는 것은 좋지 않다.
  • 커버리지 지표는 좋은 부정 지표지만 나쁜 긍정 지표다.
    • 커버리지 숫자가 낮으면(예 60% 미만) 문제 징후라 할 수 있다.
    • 코드베이스에 테스트되지 않은 코드가 많다는 뜻이다.
    • 그러나 높은 숫자도 별 의미는 없다.

1.4 무엇이 성공적인 테스트 스위트를 만드는가?

  • 테스트 스위트의 품질을 측정하는 믿을만한 방법은 스위트 내 각 테스트를 하나씩 따로 평가하는 것뿐이다.
  • 테스트 스위트가 얼마나 좋은지 자동으로 확인할 수 없다. 개인 판단에 맡겨야 한다.
  • 성공적인 테스트 스위트는 아래와 같은 특성을 가지고 있다.
    • 개발 주기에 통합돼 있다.
    • 코드베이스에서 가장 중요한 부분만을 대상으로 한다.
    • 최소한의 유지비로 최대의 가치를 끌어낸다.

1.4.1 개발 주기에 통합돼 있음

  • 모든 테스트는 개발 주기에 통합돼야 한다.
  • 코드가 변경될 때마다 아무리 작은 것이라도 실행해야 한다.

1.4.2 코드베이스에서 가장 중요한 부분만을 대상으로 함

  • 대부분의 애플리케이션에서 가장 중요한 부분은 바로 비지니스 로직(도메인 모델)이 있는 부분이다.
  • 비지니스 로직 테스트가 시간 투자 대비 최고의 수익을 낼 수 있다.
  • 통합 테스트와 같이 일부 테스트는 시스템이 전체적으로 작동하는지 확인할 수 있으니 괜찮다.
    • 그러나 초점은 도메인 모델에 머물러야 한다.
  • 도메인 모델을 코드베이스 중 중요하지 않은 부분과 분리해야 한다.

1.4.3 최소 유지비로 최대 가치를 끌어냄

  • 가치가 유지비를 상회하는 테스트만 스위트에 유지하는 것이 중요하다.
    • 가치 있는 테스트(더 나아가, 가치가 낮은 테스트) 식별하기
    • 가치 있는 테스트 작성하기
  • 가치 있는 테스트를 작성하려면 코드 설계 기술도 알아야 한다.
  • 단위 테스트와 기반 코드는 서로 얽혀 있으므로 코드베이스에 노력을 많이 기울이지 않으면 가치 있는 테스트를 만들 수 없다.

요약

  • 단위 테스트의 목표는 소프트웨어 프로젝트가 지속적으로 성장하게 하는 것이다.
  • 테스트 스위트 내에 가치 있는 테스트만 남기고 나머지는 모두 제거하라.
    • 애플리케이션과 테스트 코드는 모두 자산이 아니라 부채다.
  • 커버리지 지표가 낮다는 것은 문제의 징후이지만, 커버리지가 높다고 해서 테스트 스위트의 품질이 높은 것은 아니다.
  • 단위 테스트의 목표를 달성하기 위한 유일한 방법은 다음과 같다.
    • 좋은 테스트와 좋지 않은 테스트를 구별하는 방법을 배운다.
    • 테스트를 리팩터링해서 더 가치 있게 만든다.