4장, 좋은 단위 테스트의 4대 요소

2022.03.19

4.1 좋은 단위 테스트이 4대 요소 자세히 살펴보기

좋은 단위 테스트에는 다음과 같은 네 가지 특성이 있다.

  1. 회귀 방지
  2. 리팩터링 내성
  3. 빠른 피드백
  4. 유지 보수성

4.1.1 첫 번째 요소: 회귀 방지

회귀는 소프트웨어 버그다. 코드를 수정한 후(일반적으로 새 기능을 출시한 후) 기능이 의도한 대로 작동하지 않는 경우다.
프로그램의 코드베이스가 커질수록 잠재적인 버그에 더 많이 노출된다. 그렇기 때문에 회귀에 대해 효과적인 보호를 개발하는 것이 중요하다.
이러한 보호가 없다면 프로젝트가 오랫동안 성장할 수 없으며 점점 더 많은 버그가 쌓인다.

회귀 방지 지표에 대한 테스트 점수를 평가하려면 다음 사항을 고려해야 한다.

  • 테스트 중에 실행되는 코드의 양
  • 코드 복잡도
  • 코드의 도메인 유의성

일반적으로 실행되는 코드가 많을수록 테스트에서 회귀가 나타날 가능성이 높다.
코드의 양뿐 아니라 복잡도와 도메인 유의성도 중요하다.
복잡한 비지니스 로직을 나타내는 코드가 보일러플레이트 코드보다 훨씬 더 중요하다. 비지니스에 중요한 기능에서 발생한 버그가 가장 큰 피해를 입기 때문이다.

프레임워크, 라이브러리 등 외부 코드들도 소프트웨어 작동에 영향을 많이 미치기 때문에 테스트 범주에 포함시켜
소프트웨어가 이러한 의존성에 대해 검증이 올바른지 확인해야 한다.

회귀 방지 지표를 극대화하려면 테스트가 가능한 한 많은 코드를 실행하는 것을 목표로 해야 한다.

4.1.2 두 번째 요소: 리팩터링 내성

리팩터링하고 나서 실제 기능은 잘 동작하지만 테스트가 실패하는 경우가 있다.
이러한 상황을 거짓 양성(false positive)이라고 한다.
거짓 양성은 일반적으로 코드를 리팩터링할 때, 구현을 수정하지만 식별할 수 있는 동작은 유지할 때 발생한다.

리팩터링 내성 지표에서 테스트 점수가 얼마나 잘 나오는지 평가하려면 얼마나 많이 거짓 양성이 발생하는지 살펴봐야 한다. 적을수록 좋다.

거짓 양성은 아래와 같은 이유로 좋지 않다.

  • 테스트가 타당한 이유 없이 실패하면, 코드 문제에 대응하는 능력과 의지가 희석된다.
    • 시간이 흐르면서 이러한 실패에 익숙해지고 더 이상 신경을 쓰지 않게 된다.
  • 거짓 양성이 빈번하면 테스트 스위트에 대한 신뢰가 서서히 떨어진다.
    • 신뢰가 부족해지면 리팩터링이 줄어든다. ( 회귀를 피하기 위해 코드 변경을 최소한으로 하기 때문 )

4.1.3 무엇이 거짓 양성의 원인인가?

테스트에서 발생하는 거짓 양성의 수는 테스트 구성 방식과 직접적인 관련이 있다.
테스트와 테스트 대상 시스템(SUT)의 구현 세부 사항이 많이 결합할수록 허위 경보가 더 많이 생긴다.

거짓 양성이 생길 가능성을 줄이는 방법은 해당 구현 세부 사항에서 테스트를 분리하는 것뿐이다.

[Fact]
public void MessageRenderer_uses_correct_sub_renderers()
{
  var sut = new MessageRenderer();

  IReadOnlyList<IRenderer> renderers = sut.SubRenderers;

  Assert.Eqaul(3, renderers.Count);
  Assert.IsAssignableFrom<HeaderRenderer>(renderers[0]);
  Assert.IsAssignableFrom<BodyRenderer>(renderers[1]);
  Assert.IsAssignableFrom<FooterRenderer>(renderers[2]);
}

위 테스트는 하위 렌더링 클래스가 예상하는 모든 유형이고 올바른 순서로 나타나는지 여부를 확인한다.
처음에는 테스트가 좋아 보이지만, MessageRenderer의 식별할 수 있는 동작을 실제로 확인하거나, 하위 렌더링 클래스를 재배열하거나 그중 하나를 새 것으로 교체하면 어떻게 될까?

버그로 이어지는가? 반드시 그렇지는 않다.

최종 결과가 바뀌지 않을지라도 테스트를 수행하면 빨간색으로 변할 것이다.
테스트가 SUT가 생성한 결과가 아니라 SUT의 구현 세부 사항과 결합했기 때문이다.

MessageRenderer 클래스의 상당 부분을 리팩터링하면 테스트가 실패한다.
리팩터링 과정은 애플리케이션의 식별할 수 있는 동작에 영향을 주지 않으면서 구현을 변경하는 것이기 때문이다.
이는 테스트가 SUT의 구현 세부사항과 결합되어 있어 리팩터링에 내성이 없는 것이다.

4.1.4 구현 세부 사항 대신 최종 결과를 목표로 하기

[Fact]
public void Rendering_a_message()
{
  var sut = new MessageRenderer();
  var message = new Message
  {
    Header = "h",
    Body = "b",
    Footer = "f"
  }
  string html = sut.Render(message);
  Assert.Eqaul("<h1>h</h1><b>b</b><i>f</i>", html);
}

위 테스트는 MessageRenderer를 블랙박스로 취급하고 식별할 수 있는 동작에만 신경 쓴 것이다.
결과적으로 테스트는 리팩터링 내성이 부쩍 늘어났다.
HTML 출력을 똑같이 지키는 한, SUT의 변경 사항은 테스트에 영향을 미치지 않는다.


하지만 이와 같은 테스트도 거짓 양성이 있을 수 있다.
예를 들어 Render() 메서드에 새 매개변수를 도입하면 컴파일 오류가 발생할 수 있다.
기술적으로 이러한 오류도 거짓 양성으로 간주한다.

하지만 이러한 거짓 양성은 해결하기가 쉽다. 좋지 않은 거짓 양성은 컴파일 오류를 내지 않는 것이다.

4.2 첫 번째 특성과 두 번째 특성 간의 본질적인 관계

좋은 단위 테스트의 두 요소(회귀 방지, 리팩터링 내성) 사이에는 본질적인 관계가 있다.
둘 다 정반대의 관점에서도 테스트 스위트의 정확도에 기여한다.
이 두 가지 특성은 시간이 흐르면서 프로젝트에 영향을 다르게 미치는 경향이 있다.

프로젝트가 시작된 직후에는 회귀 방지를 훌륭히 갖추는 것이 중요한 데 반해, 리팩터링 내성은 필요하지 않다.

4.2.1 테스트 정확도 극대화

코드 정확도와 테스트 결과에 대해서는 아래와 같이 네 가지 결과가 있을 수 있다.


거짓 양성과 거짓 음성의 확률은 테스트의 수준을 나타낸다. 즉, 확률이 낮을 수록 테스트가 더 정확하다.

회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도를 극대화하는 것을 목표로 한다. 정확도 지표는 다음 두 가지 요소로 구성된다.

  • 테스트가 버그 있음을 얼마나 잘 나타내는가(거짓 음성(회귀 방지 영역) 제외).
  • 테스트가 버그 없음을 얼마나 잘 나타내는가(거짓 양성(리팩터링 내성 영역) 제외).

거짓 양성과 거짓 음성을 생각해보는 다른 방법으로 소음 대비 신호 비율 측면에서 볼 수 있다.

테스트 정확도 = 신호(발견된 버그 수) / 소음(허위 경보 발생 수)

4.2.2 거짓 양성과 거짓 음성의 중요성: 역학 관계


프로젝트 초기에는 거짓 양성이 중요하지 않다. 초기에는 리팩터링(코드 정리)을 할 필요가 없기 때문이다.
시간이 흐름에 따라 리팩터링이 필요해지고, 이에 따라 리팩터링 내성도 점점 더 중요해진다.
프로젝트가 길어지고 사이즈가 비대해질수록 거짓 음성과 거짓 양성에 대해 똑같이 주의를 기울여야 한다.

4.3 세 번째 요소와 네 번째 요소: 빠른 피드백과 유지 보수성

  1. 빠른 피드백
  • 테스트 속도가 빠를수록 테스트 스위트에서 더 많은 테스트를 수행할 수 있고 자주 실행할 수 있다.
  • 테스트가 빠르게 실행되면 코드에 결함이 생기자마자 버그에 대해 경고하기 시작할 정도로 피드백 루프를 줄일 수 있다.
  1. 유지 보수성
  • 테스트가 얼마나 이해하기 어려운가
    • 테스트는 코드 라인이 적을수록 읽기 쉽다. (변경도 쉽다.)
    • 테스트 코드의 품질은 제품 코드만큼 중요하다.
  • 테스트가 얼마나 실행하기 어려운가
    • 테스트가 프로세스 외부 종속성으로 작동하면, 데이터베이스를 재부팅하고 네트워크 연결 문제를 해결하는 등 의존성을 상시 운영하는데 시간을 들여야 한다.

4.4 이상적인 테스트를 찾아서

  • 회귀 방지, 리팩터링 내성, 빠른 피드백, 유지 보수성

위의 네 가지 특성을 곱하면(수학적으로) 테스트의 가치가 결정된다. ( 어떤 특성이라도 0이 되면 전체가 0이 된다.)

가치가 있으려면 테스트는 네 가지 범주 모두에서 점수를 내야 한다.

물론, 이러한 특성을 정확하게 측정하는 것은 불가능하다. 관련된 코드 분석 도구도 없다.
그러나 네 가지 특성과 관련해서 테스트가 어디쯤 있는지는 비교적 정확하게 평가할 수 있다.
이 평가는 테스트의 가치 추정치를 제시하며, 이 추정치로 테스트 스위트에 테스트를 계속 둘지 여부를 결정할 수 있다.

테스트 코드를 포함한 모든 코드는 책임이다.
최소 필수값에 대해 상당히 높은 임계치를 설정하고 이 것을 충족하는 테스트만 테스트 스위트에 남겨라.
소수의 매우 가치 있는 테스트는 다수의 평범한 테스트보다 프로젝트가 계속 성장하는 데 훨씬 효과적이다.

4.4.1 이상적인 테스트를 만들 수 있는가?

네 가지 특성에서 최대 점수를 가지는 이상적인 테스트를 만드는 것은 불가능하다.
회귀 방지, 리팩터링 내성, 빠른 피드백은 상호 베타적이기 때문이다.
셋 중 하나를 희생해야 나머지 둘을 최대로 할 수 있다.

4.4.2 극단적인 사례 1: 엔드 투 엔드 테스트


엔드 투 엔드 테스트는 많은 코드를 테스트하므로 회귀 방지를 훌륭히 해낸다.
또한, 거짓 양성에 면역이 돼 리팩터링 내성도 우수하다.
하지만 느린 속도로 인해 빠른 피드백을 받기가 어려운 단점이 있다.
이는 엔드 투 엔드 테스트만으로 코드 베이스를 다루기가 불가능한 이유이기도 하다.

4.4.3 극단적인 사례 2: 간단한 테스트

public class User
{
  public string Name { get; set; }
}

[Fact]
public void Test()
{
  var sut = new User();

  sut.Name = "John Smith";

  Assert.Eqaul("John Smith", sut.Name);
}

간단한 테스트는 매우 빠르게 실행되고 거짓 양성이 생길 가능성이 상당히 낮기 때문에 리팩터링 내성도 우수하다.
그러나 기반 코드에 실수할 여지가 많지 않기 때문에 간단한 테스트는 회귀를 나타내지 않을 것이다.


4.4.4 극단적인 사례 3: 깨지기 쉬운 테스트

실행이 빠르고 회귀를 잡을 가능성이 높지만 거짓 양성이 많은 테스트를 깨지기 쉬운 테스트(brittle test)라고 한다.

[Fact]
public void GetByid_executes_correct_SQL_code()
{
  var sut = new UserRepository();

  User user = sut.GetById(5);

  Assert.Eqaul(
    "SELECT * FROM dbo.[USER] WHERE UserID = 5", sut.LastExecutedSqlStatement);
  )
}

위의 SQL문을 여러 가지 형태로 변형해도 결과는 모두 같을 수 있다.

SELECT * FROM dbo.[User] WHERE UserID = 5
SELECT * FROM dbo.User WHERE UserID = 5
SELECT UserID, Name, Email FROM dbo.[User] WHERE UserID = 5

기능은 계속 작동하지만 테스트가 SUT의 구현 세부 사항에 결합되어 있기 때문에 리팩터링 내성이 전혀 없다.

4.4.5 이상적인 테스트를 찾아서: 결론

좋은 단위 테스트의 처음 세가지 특성(회귀 방지, 리팩터링 내성, 빠른 피드백)은 상호 베타적이다.
두 가지를 극대화하는 테스트를 만들기는 매우 쉽지만, 나머지 특성 한 가지를 희생해야 한다.


리팩터링 내성을 포기할 수 없는 이유는 테스트가 이 특성을 갖고 있는지의 여부는 대부분 이진 선택이기 때문이다. 즉, 테스트에 리팩터링 내성이 있거나 없거나 둘 중 하나다.
따라서 리팩터링 내성을 조금만 인정할 수는 없다.

4.5 대중적인 테스트 자동화 개념 살펴보기

4.5.1 테스트 피라미드 분해


테스트 피라미드 상단의 테스트는 회귀 방지에 유리한 반면, 하단은 실행 속도를 강조한다.
또한 어느 계층도 리팩터링 내성을 포기하지 않는다.

테스트 유형 간의 정확한 비율은 팀과 프로젝트마다 다를 것이다. 그러나 일반적으로 피라미드 형태를 유지해야 한다.
엔드 투 엔드 테스트가 가장 적고, 단위 테스트가 가장 많으며, 통합 테스트는 중간 어딘가에 있어야 한다.
엔드 투 엔드 테스트는 피드백 점수가 매우 낮고, 유지 보수성이 결여되어 있다.
따라서 가장 중요한 기능에 적용하는 것이 좋다.

테스트 피라미드에는 예외가 있다. 예를 들어 비지니스 규칙이나 복잡도가 거의 없는 기본적인 CRUD 작업이라면, 단위 테스트와 통합 테스트의 수가 같고 엔드 투 엔드 테스트가 없는 직사각형처럼 보일 수 있다.

4.5.2 블랙박스 테스트와 화이트박스 테스트 간의 선택

  • 블랙박스 테스트는 시스템의 내부 구조를 몰라도 시스템의 기능을 검사할 수 있는 소프트웨어 테스트 방법.
    • 일반적으로 명세와 요구 사항을 가지고 애플리케이션이 무엇을 해야 하는지를 중심으로 구축된다.
  • 화이트 박스 테스트는 애플리케이션의 내부 작업을 검증하는 테스트 방식.
    • 요구 사항이나 명세가 아닌 소스 코드에서 파생된다.

이 두 가지 방법 모두 아래와 같은 장단점이 있다.

회귀 방지리팩터링 내성
화이트박스 테스트좋음나쁨
블랙박스 테스트나쁨좋음

리팩터링 내성은 타협할 수 없기 때문에, 블랙박스 테스트를 기본으로 선택하라.
테스트를 작성할 때는 블랙박스 테스트가 바람직하지만, 테스트를 분석할 때는 화이트박스 방법을 사용할 수 있다.

코드 커버리지 도구를 사용해서 어떤 코드 분기를 실행하지 않았는지 확인한 다음 코드 내부 구조에 대해 전혀 모르는 것처럼 테스트하라.
이와 같이 조화롭게 사용하는 것이 가장 효과적이다.

요약

  • 좋은 단위 테스트에는 네 가지 기본 특성이 있다.
    • 회귀 방지
    • 리팩터링 내성
    • 빠른 피드백
    • 유지 보수성
  • 회귀 방지는 테스트가 얼마나 버그의 존재를 잘 나타내는지에 대한 척도다.
  • 리팩터링 내성은 테스트가 거짓 양성을 내지 않고 코드 리팩터링을 유지할 수 있는 정도를 의미한다.
  • 회귀 방지와 리팩터링 내성은 테스트 정확도에 기여한다.
  • 빠른 피드백은 테스트가 얼마나 빨리 실행되는지에 대한 척도다.
  • 유지 보수성은 두 가지 요소로 구성된다.
    • 테스트 이해 난이도. 테스트가 작을수록 읽기 쉽다.
    • 테스트 실행 난이도. 테스트에 관련된 프로세스 외부 의존성은 적을수록 쉽게 운영할 수 있다.
  • 리팩터링 내성은 타협할 수 없다.
    • 특성 간의 절충은 회귀 방지와 빠른 피드백 사이의 선택으로 귀결된다.
  • 테스트를 작성할 때는 블랙박스 테스트 방법을, 테스트를 분석할 때는 화이트박스 방법을 사용하라.