5장, 목과 테스트 취약성

2022.03.28

5.1 목과 스텁 구분

5.1.1 테스트 대역 유형

테스트 대역은 모든 유형의 비운영용 가짜 의존성을 설명하는 포괄적인 용어다.
테스트 대역의 주 용도는 테스트를 편리하게 하는 것이며, 실제 의존성 대신 전달되므로 설정이나 유지보수가 어려울 수 있다.

테스트 대역에는 더미(dummy), 스텁(stub), 스파이(spy), 목(mock), 페이크(fake)라는 다섯 가지가 있다.
실제로는 목과 스텁의 두 가지 유형으로 나눌 수 있다.


  • 목은 외부로 나가는 상호 작용을 모방하고 검사하는데 도움이 된다.
    • SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당.
    • 스텁과 달리 SUT와 관련 의존성 간의 상호 작용을 모방하고 검사.
  • 스텁은 내부로 들어오는 상호 작용을 모방하는 데 도움이 된다.
    • SUT가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당.

다섯 가지 변형의 차이는 미미한 구현 세부 사항이다.

  • 스파이: 목과 같은 역할을 수동으로 작성.
  • 목 : 프레임워크의 도움을 받아 생성.

스텁, 더미, 페이크의 차이는 얼마나 똑똑한지에 있다.

  • 더미: 널 값이나 가짜 문자열과 같이 단순하고 하드코딩된 값.
  • 스텁: 시나리오마다 다른 값을 반환하게끔 구성할 수 있도록 필요한 것을 다 갖춘 완전한 의존성.
  • 페이크: 아직 존재하지 않는 의존성을 대체하고자 구현.

5.1.2 도구로서의 목과 테스트 대역으로서의 목

1[Fact]
2Public void Sending_a_greetings_email()
3{
4    var mock = new Mock<IEmailGateway>();   // Mock(도구)으로 mock(목) 생성
5Var sut = new Controller(mock.Object);
6
7sut.GreetUser(“user@gmail.com”);
8mock.Verify(x => x.SendGreetingsEmail(“user@email.com”), Timers.Once); // 테스트 대역으로 하는 SUT의 호출을 검사
9}

위의 테스트는 목 라이브러리에 있는 Mock 클래스를 사용한다. 이 클래스는 테스트 대역(목)을 만들 수 있는 도구다. 다시 말해 Mock 클래스는 도구로서의 목인 데 반해, 해당 클래스의 인스턴스인 mock은 테스트 대역으로서의 목이다.

1[Fact]
2Public void Creating_a_report()
3{
4    var stub = new Mock<IDatabase>();   // Mock(도구)으로 스텁 생성
5    stub.Setup(x => x.GetNumberOfUsers()).Returns(10); //  준비한 응답 설정
6
7    var sut = new Controller(stub.Object);
8
9    Report report = sut.CreateReport();
10
11    Assert.Equal(10, report.NumberOfUsers);
12}

이 테스트 대역은 내부로 들어오는 상호 작용, SUT에 입력 데이터를 제공하는 호출을 모방한다.
반면 이전 예제에서 SendGreetingsEmail에 대한 호출은 외부로 나가는 상호 작용이고, 그 목적은 부작용을 일으키는 것(이메일 발송)뿐이다.

5.1.3 스텁으로 상호 작용을 검증하지 말라

SUT에서 스텁으로의 호출은 SUT가 생성하는 최종 결과가 아니다. 이러한 호출은 최종 결과를 산출하기 위한 수단일 뿐이다.
즉, 스텁은 SUT가 출력을 생성하도록 입력을 제공한다.

스텁과의 상호 작용을 검증하는 것은 취약한 테스트를 야기하는 일반적인 안티 패턴이다.

1[Fact]
2Public void Creating_a_report()
3{
4    var stub = new Mock<IDatabase>();
5    stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
6
7    var sut = new Controller(stub.Object);
8
9    Report report = sut.CreateReport();
10
11    Assert.Equal(10, report.NumberOfUsers);
12    stub.Verify(x => x.GetNumberOfUsers(), Times.Once); // 스텁으로 상호 작용 검증
13}

최종 결과가 아닌 사항을 검증하는 이러한 관행을 과잉 명세(overspecification)라고 부른다.
과잉 명세는 상호 작용을 검사할 때 가장 흔하게 발생한다. 테스트는 스텁과의 상호 작용을 확인 해서는 안 된다.

GetNumberOfUsers()를 호출하는 것은 전혀 결과 가 아니며, 이는 SUT가 필요한 보고서 작성에 필요한 데이터를 수집하는 방법에 대한 내부 구현 세부 사항이다.

5.1.4 목과 스텁 함께 쓰기

때로는 목과 스텁의 특성을 모두 나타내는 테스트 대역을 만들 필요가 있다.

1[Fact]
2Public void Purchase_fails_when_not_enough_inventory()
3{
4    var storeMock = new Mock<IStore>();
5    storeMock
6        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5)).Returns(false);  // 준비된 응답을 설정
7
8    var sut = new Customer();
9
10    bool success = sut.Purchase(storeMock.Object, Product.Shampoo, 5);
11
12    Assert.False(success);
13    storeMock.Verify(x => x.RemoveInventory(Product.Shampoo, 5), Times.Never); // SUT에서 수행한 호출을 검사
14}

테스트 대역은 목이면서 스텁이지만, 여전히 목이라고 부르지 스텁이라고 부르지는 않는다.
목이라는 사실이 스텁이라는 사실보다 더 중요하기 때문에 대체로 목이라고 한다.

5.1.5 목과 스텁은 명령과 조회에 어떻게 관련돼 있는가?

목과 스텁의 개념은 명령 조회 분리(CQS, Command and Query Separation) 원칙과 관련이 있다.
이 원칙에 따르면 모든 메서드는 명령이거나 조회여야 하며, 이 둘을 혼용해서는 안 된다.
명령을 대체하는 테스트 대역은 목이고, 조회를 대체하는 테스트대역은 스텁이다.


5.2 식별할 수 있는 동작과 구현 세부 사항

단위테스트는 리팩터링 내성 지표가 가장 중요하다.
따라서 테스트가 단위 테스트 영역에 있고 엔드 투 엔드 테스트 범주로 바뀌지 않는 한 리팩터링 내성을 최대한 활용하는 것이 좋다.

테스트는 '어떻게'가 아니라 '무엇'에 중점을 둬야한다.
구현 세부사항은 정확히 무엇이고 식별할 수 있는 동작과 어떻게 다른지 알아보자.

5.2.1 식별할 수 있는 동작은 공개 API와 다르다

모든 제품 코드는 2차원으로 분류할 수 있다.

  • 공개 API 또는 비공개 API
  • 식별할 수 있는 동작 또는 구현 세부 사항

식별할 수 있는 동작과 내부 구현 세부 사항에는 미묘한 차이가 있다.
코드가 시스템의 식별할 수 있는 동작이라면 다음 중 하나를 해야 한다.

  • 클라이언트가 목표를 달성하는 데 도움이 되는 연산을 노출하라.
  • 클라이언트가 목표를 달성하는 데 도움이 되는 상태를 노출하라.

코드가 식별할 수 있는 동작인지 여부는 해당 클라이언트가 누구인지, 목표가 무엇인지에 달려 있다.
이상적으로 시스템의 공개 API는 식별할 수 있는 동작과 일치해야 하며, 모든 구현 세부사항은 클라이언트 눈에 보이지 않아야 한다.

5.2.2 구현 세부 사항 유출: 연산의 예

1// API 설계가 잘 못된 예제, 구현 세부 사항을 유출하는 User 클래스
2public class User
3{
4    public string Name { get; set; }
5
6    public string NormalizeName(string Name)
7    {
8        string result = (name ?? "").Trim();
9
10        if (result.Length > 50)
11            return result.Substring(0, 50);
12
13        return result;
14    }
15}
16
17...
18
19User user = ...
20
21string nomrmalizedName = user.NormalizeName(newName);
22user.Name = nomrmalizedName;
1// API 설계가 잘 된 예제, 구현 세부 사항은 비공개
2public class User
3{
4    private string _name;
5    public string Name
6    {
7        get => _name;
8        set => _name = NormalizeName(value);
9    }
10
11    public string NormalizeName(string Name)
12    {
13        string result = (name ?? "").Trim();
14
15        if (result.Length > 50)
16            return result.Substring(0, 50);
17
18        return result;
19    }
20}
21
22...
23
24User user = ...
25
26user.Name = newName;

클래스가 구현 세부 사항을 유출하는지 판단하는 데 도움이 되는 유용한 규칙이 있다.
단일한 목표를 달성하고자 클래스에서 호출해야하는 연산의 수가 1보다 크면 해당 클래스에서 구현 세부 사항을 유출할 가능성이 있다.

1string normalzedName = user.NormalizeName(newName);
2user.Name = normalzedName;
3
4
5// 리팩터링 후
6user.Name = newName;

리팩터링 후에 연산 수가 1로 감소했다.
이 규칙은 예외는 있을 수 있지만 비지니스 로직이 포함된 대부분의 경우에 적용된다.

5.2.3 잘 설계된 API와 캡슐화

잘 설계된 API를 유지 보수하는 것은 캡슐화 개념과 관련이 있다. 캡슐화는 불변성 위반이라고도 하는 모순을 방지하는 조치다.

장기적으로 코드베이스 유지보수에서는 캡슐화가 중요하다. 복잡도 때문이다.
개발자 스스로가 항상 옳게ㅔ만 한다고 믿을수는 없으므로 실수할 가능성을 최대한 없애라. 이렇게 하는 데 가장 좋은 방법은 캡슐화를 유지해 코드베이스에서 잘못할 수 있는 옵션조차 제공하지 않도록 하는 것이다.

  • 구현 세부 사항을 숨기면 클라이언트의 시야에서 클래스 내부를 가릴 수 있기 때문에 내부를 손상시킬 위험이 적다.
  • 데이터와 연산을 결합하면 해당 연산이 클래스의 불변성을 위반하지 않도록 할 수 있다.

5.2.5 구현 세부사항 유출: 상태의 예

좋은 단위 테스트와 잘 설계된 API 사이에는 본질적인 관계가 있다. 모든 구현 세부 사항을 비공개로 하면 테스트가 식별할 수 있는 동작을 검증하는 것 외에는 다른 선택지가 없으며, 이로 인해 리팩터링 내성도 자동으로 좋아진다.

API를 잘 설계하면 단위 테스트도 자동으로 좋아진다.

연산과 상태를 최소한으로 노출해야 한다. 클라이언트가 목표를 달성하는 데 직접적으로 도움이 되는 코드만 공개해야 하며, 다른 모든 것은 구현 세부 사항이므로 비공개로 두어야한다.

----식별할 수 있는 동작구현 세부 사항
공개좋음나쁨
비공개해당 없음좋음

5.3 목과 테스트 취약성 간의 관계

5.3.1 육각형 아키텍처 정의


육각형 아키텍처의 목적은 아래 세 가지 중요한 지침을 강조한다.

  • 도메인 계층과 애플리케이션 서비스 계층 간의 관심사 분리
    • 도메인 계층은 해당 배지니스 로직에 대해서만 책임을 져야 하며, 다른 모든 책임에서는 제외돼야 한다.
    • 외부 애플리케이션과 통신하거나 데이터베이스에서 데이터를 검색하는 것과 같은 책임은 애플리케이션 서비스에 귀속돼야 한다.
  • 애플리케이션 내부 통신
    • 육각형 아키텍처는 애플리케이션 서비스 계층에서 도메인 계층으로 흐르는 단방향 의존성 흐름을 규정한다.
    • 도메인 계층 내부 클래스는 도메인 계층 내부 클래스끼리 서로 의존하고 애플리케이션 서비스 계층의 클래스에 의존하지 않는다.
  • 애플리케이션 간의 통신
    • 외부 애플리케이션은 애플리케이션 서비스 계충에 있는 공통 인터페이스를 통해 해당 애플리케이션에 연결된다.

잘 설계된 API의 원칙에는 프랙탈(fractal) 특성이 있는데, 이는 전체 계층만큼 크게도, 단일 클래스만큼 작게도 똑같이 적용되는 것이다.

각 계층의 API를 잘 설계하면(구현 세부 사항을 숨기면) 테스트도 프랙탈 구조를 갖기 시작한다.
애플리케이션 서비스를 다루는 테스트는 해당 서비스가 외부 클라이언트에게 매우 중요하고 큰 목표를 어떻게 이루는지 확인한다.
도메인 클래스 테스트는 그 큰 목표의 하위 목표를 검증한다.


코드베이스의 공개 API를 항상 비지니스 요구 사항에 따라 추적하라는 이 지침은 대부분의 도메인 클래스와 애플리케이션 서비스에 적용되지만, 유틸리티나 인프라 코드에는 적용되지 않는다. 해당 코드로 해결하는 문제는 종종 너무 낮은 수준이고 세밀해서 구체적인 비지니스 유스케이스로 추적할 수 없다.

5.3.2 시스템 내부 통신과 시스템 간 통신

일반적인 애플리케이션에는 시스템 내부 통신과 시스템 간 통신이 있다.

시스템 내부 통신은 구현 세부 사항이고, 시스템 간 통신은 그렇지 않다.

연산을 수행하기 위한 도메인 클래스 간의 협력은 식별할 수 있는 동작이 아니므로 시스템 내부 통신은 구현 세부 사항에 해당한다.

시스템 간 통신의 특성은 별도 애플리케이션과 함께 성장하는 방식에서 비롯된다. 성장의 주요 원칙 중 하나로 하위 호환성을 지키는 것이다.
목을 사용하면 시스템과 외부 애플리케이션 간의 통신 패턴을 확인할 때 좋다.
반대로 시스템 내 통신을 검증하는 데 사용하면 테스트가 구현 세부 사항과 결합되어, 리팩토링 내성 지표가 미흡해진다.

5.3.3 시스템 내부 통신과 시스템 간 통신의 예

다음 비지니스 유스케이스를 보자.

  • 고객이 상점에서 제품을 구매하려고 한다.
  • 매장 내 제품 수량이 충분하면
    • 재고가 상점에서 줄어든다.
    • 고객에게 이메일로 영수증을 발송한다.
    • 확인 내역을 반환한다.

이 시스템의 목표는 구매를 하는 것이며, 고객이 성공적인 결과로서 이메일로 확인 내역을 받는 것을 기대한다.


육각형 간의 통신은 시스템 간의 통신, 육각형 내부의 통신은 시스템 내부 통신이다.
SMTP 서비스에 대한 호출을 목으로 하는 이유는 타당하다.
리팩터링 후에도 이러한 통신 유형이 그대로 유지되도록 하기 때문에 테스트 취약성을 야기하지 않는다.

5.4 단위 테스트의 고전파와 런던파 재고

5.4.1 모든 프로세스 외부 의존성을 목으로 해야 하는 것은 아니다

모든 프로세스 외부 의존성을 목으로 해야 하는 것은 아니다.
프로세스 외부 의존성이 애플리케이션을 통해서만 접근할 수 있으면, 이러한 의존성과의 통신은 시스템에서 식별할 수 있는 동작이 아니다.

애플리케이션이 외부 시스템에 대한 프록시 같은 역할을 하고 클라이언트가 직접 접근할 수 없으면, 하위 호환성 요구 사항을 사라진다.
이 외부 시스템과 애플리케이션을 같이 배포할 수 있으면 클라이언트에 영향을 미치지 않을 것이다.
시스템의 통신 패턴은 구현 세부 사항이 된다.

완전히 통제권을 가진 프로세스 외부 의존성에 목을 사용하면 깨지기 쉬운 테스트로 이어진다.
데이터베이스에서 테이블을 분할하거나 저장 프로시저에서 매개변수 타입을 변경할 때마다 테스트가 빨간색이 되는 것을 아무도 원하지 않는다.
데이터베이스와 애플리케이션은 하나의 시스템으로 취급해야 한다.

5.4.2 목을 사용한 동작 검증

종종 목이 동장을 검증한다고 한다. 하지만 대부분의 경우 그렇지 않다.
목표를 달성하고자 각 개별 클래스가 이웃 클래스와 소통하는 방식은 식별할 수 있는 동작과는 아무런 관계가 없다.

목은 애플리케이션의 경계를 넘나드는 상호 작용을 검증할 때와 이러한 상호 작용의 부작용이 외부 환경에서 보일 때만 동작과 관련이 있다.

요약

  • 테스트 대역에는 더미, 스텁, 스파이, 목, 페이크 등의 다섯 가지 변형이 있다.
    • 이는 다시 목과 스텁이라는 두 가지 유형으로 분류할 수 있다.
    • 스파이는 기능적으로 목과 같고, 더미와 페이크는 스텁과 같은 역할을 한다.
  • 목은 외부로 나가는 상호작용을 모방하고 검사하는 데 도움이 된다.
  • 스텁은 내부로 들어오는 상호작용을 모방하는 데 도움이 된다.
  • 명령 조회 분뢰(CQS) 원칙에 따르면, 모든 메서드가 명령 또는 조회 중 하나여야 하지만 둘 다는 안 된다.
    • 명령을 대체하는 테스트 대역은 목이고 조회를 대체하는 테스트 대역은 스텁이다.
  • 잘 설계된 코드는 식별할 수 있는 동작이 공개 API와 일치하고 구현 세부 사항이 비공개 API 뒤에 숨겨져 있는 코드다.
    • 공개 API가 식별할 수 있는 동작 이상으로 커지면 코드는 구현 세부 사항을 유출한다.
  • 애플리케이션에는 시스템 내부 통신과 시스템 간 통신이라는 두 가지 통신 유형이 있다.
    • 시스템 내부 통신: 애플리케이션 내 클래스 간의 통신
    • 시스템 외부 통신: 애플리케이션이 외부 애플리케이션과 통신
  • 시스템 내의 통신을 검증하고자 목을 사용하면 취약한 테스트로 이어진다.
    • 따라서 시스템 간 통신과 해당 통신의 부작용이 외부 환경에서 보일 때만 목을 사용하는 것이 타당하다.