031. (Unit Test Principles) 5. Mock과 테스트 취약성

5. Mock과 테스트 취약성

원제 : Mocks and test fragility

이번엔 앞선 포스팅에서 다룬 단위 테스트 방법을 분석하는 데 사용했던 기준들이 실제로 어떻게 적용되는지 확인해보도록 하자.

테스트에서 Mock을 사용하는 것은 여전히 논란의 주제가 있다.

누군가는 Mock을 훌륭한 도구이며 대부분의 테스트에 적용해야한다고 주장하고,

반대편에서는 Mock은 오히려 테스트의 취약성을 초래하며 사용하면 안된다고 주장한다.

본 포스팅에서는 Mock으로 인해 취약한 테스트, 바꿔 말해 Mock으로 인해 리팩토링 내성이 부족한 테스트가 유발되는 경우를 살펴볼 것이다.

또한 위의 주장에서 알 수 있듯 단위 테스트의 런던파와 고전파에 대한 논쟁을 좀 더 집중적으로 다루어 볼 것이다.

먼저 Mock과 테스트 취약성 사이에 어떤 관련이 있는지 살펴보도록 하자.

참고 필자는 목과 스텁이라는 한글보다 Mock과 Stub이 더 의미 전달에 좋다고 생각하므로 본 포스팅에서는 영문을 차용한다.

5.1. Mock과 Stub을 구분하기

원제 : Differentiating mocks from stubs

Mock은 테스트 대상 시스템(SUT)과 그 협력자 사이의 상호 작용을 검사할 수 있는 테스트 대역이다.

이 테스트 대역의 또 다른 유형으로는 Stub이 존재한다.

Mock과 Stub이 어떻게 다른지 자세히 알아보도록 하자.

5.1.1. 테스트 대역의 유형들

원제 : The types of test doubles

테스트 대역(test double) 은 테스트를 위한 모든 유형의 가짜 의존성을 의미하는 포괄적인 용어다.

위험한 씬을 대신 촬영하는 스턴트 대역의 그 대역과 동일한 의미를 가지고 있다.

테스트 대역의 주 용도는 의존성을 끊어내어 보다 편한 테스트를 수행하는 데 있다.

다만, SUT로 실제 의존성 대신 전달되는 것이므로 설정이나 유지보수가 어려운 단점이 존재한다.

제라드 메스자로스(Gerard Meszaros) 가 정의한 바에 따르면 테스트 대역이는 Dummy Object, Test Stub, Test Spy, Mock Object, Fake Object의 다섯 종류가 존재한다.

참고 제라드 메스자로스는 그 유명한 “xUnit Test Patterns - Refactoring Test Code(2007)”의 저자이다.

다만 좀 더 대분류로 나누면 Mock과 Stub의 두 가지 유형으로 구분할 수 있다.

Figure 5.1

차이점에 대해 좀 더 명세해보면 아래와 같다.

  • Mock은 외부로 나가는 상호 작용을 모방하고 검사하는 데, 도움이 된다. 이는 SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당한다.
  • Stub은 내부로 들어오는 상호 작용을 모방하는 데 도움이 된다. 이는 SUT가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당한다.

위 차이점에서 알 수 있듯이 Mock과 Stub은 모두 상호 작용을 모방은 하는데, 검사는 Mock만 수행한다는 점을 알 수 있다.

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

원제 : Mock (the tool) vs. mock (the test double)

Mock 이라는 단어 자체가 상황에 따라 다른 의미를 가질 수도 있다.

Mock 자체를 테스트 대역이라는 의미로 사용하지만 사실은 테스트 대역의 일부일 뿐으로 또 다른 의미가 존재한다.

Mock 라이브러리(Mocking Library) 의 클래스도 Mock으로 참고가 가능한데,

이 클래스는 실제로 Mock을 생성하는 데 도움을 줄 뿐, 그 자체로는 Mock이 아니다.

아래 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
[Fact]
public void Sending_a_greetings_email() {
var mock = new Mock<IEmailGateway>(); // 도구로서의 Mock을 이용한 mock 생성
var sut = new Controller(mock.Object);

sut.GreetUser("user@email.com");

mock.Verify(x => x.SendGreetingsEmail("user@email.com"), Times.Once);
}

참고 마틴 파울러의 포스팅 - Mocks Aren’t Stubs

위의 예제는 Mock 라이브러리 내의 클래스인 Mock을 사용하였다.

따라서 Mock 클래스는 Mock을 만들 수 있는 도구일뿐이고, 이를 통해 생성한 것이 테스트 대역으로서의 Mock이라고 볼 수 있다.

문제는 도구로서의 Mock을 사용하는 경우 Stub으로도 만들 수 있다는 점이다.

이번엔 Stub을 생성하는 예제를 살펴 보자.

1
2
3
4
5
6
7
8
9
10
11
[Fact]
public void Creating_a_report() {
var stub = new Mock<IDatabase>(); // 도구로서의 Mock을 이용한 stub 생성
stub.Setup(x => x.GetNumberOfUsers())
.Returns(10);
var sut = new Controller(stub.Object);

Report report = sut.CreateReport();

Assert.Equal(10, report.NumberOfUsers);
}

Mock을 이용해 Stub을 생성하고, 사전에 응답값을 미리 설정하였다.

이처럼 Stub은 내부로 들어오는 상호작용 즉, sut를 통해 입력 데이터를 제공하는 호출 데이터만을 모방한다.

5.1.3. Stub으로 상호 작용을 검증하지말 것

원제 : Don’t assert interactions with stubs

상술했듯, Mock과 Stub은 모두 상호 작용을 모방은 하는데, 검사는 Mock만 수행하고 있다.

이 차이점은 Stub과의 상호 작용을 검증하지않아야한다는 지침에서 비롯된 것이다.

SUT에서 Stub으로의 호출은 SUT가 생성하는 최종 결과가 아닌, 단지 최종 결과를 산출하기 위한 수단일 뿐이다.

결론적으로 Stub은 SUT가 출력을 생성하도록 입력을 제공하는 테스트 대역일 뿐이다.

무슨 의미가 있나 싶겠지만, 테스트에서 거짓 양성을 회피하고 리팩토링 내성을 향상시키는 방법은 구현 세부 사항과의 커플링을 깨고 최종 결과를 검증하는 것에 있음을 잊지말자.

1
mock.Verify(x => x.SendGreetingsEmail("user@email.com"))

실제로 위 mock 코드는 실제 인사 담당자가 시스템에 요구하는 기능이지만,

1
stub.Setup(x => x.GetNumberOfUsers()).Returns(10)

위의 stub 코드는 최종 결과와 아무런 상관이 없기에 이를 검증하는 것은 오히려 테스트 취약성을 부각시키게 되어 깨지기 쉬운 테스트가 되어버린다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
[Fact]
public void Creating_a_report() {
var stub = new Mock<IDatabase>();
stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
var sut = new Controller(stub.Object);

Report report = sut.CreateReport();

Assert.Equal(10, report.NumberOfUsers);
stub.Verify(x => x.GetNumberOfUsers(), Times.Once); // HERE
}

이처럼 최종 결과가 아닌 사항을 검증하는 경우를 과잉 명세(overspecification) 이라고 부른다.

과잉 명세는 상호 작용을 검사할때 가장 흔하게 발생하며, Stub과의 상호 작용을 확인하는 것을 쉽게 발견할 수 있는 결함이기도 하다.

5.1.4. Mock과 Stub을 함께 사용하기

원제 : Using mocks and stubs together

때때로 Mock과 Stuv의 특성을 모두 가진 테스트 대역을 만들어야하는 경우도 있다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
[Fact]
public void Purchase_fails_when_not_enough_inventory() {
var storeMock = new Mock<IStore>();
storeMock.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(false);
var sut = new Customer();

bool success = sut.Purchase(storeMock.Object, Product.Shampoo, 5);

Assert.False(success);
storeMock.Verify(x => x.RemoveInventory(Product.Shampoo, 5), Times.Never);
}

여기서 storeMock 은 두 가지 목적으로 사용되었다.

먼저 준비된 응답을 반환하기 위한 Stub으로의 역할, 그리고 RemoveInventory 메서드 호출에 대한 결과를 검증하기 위한 Mock으로의 역할이다.

여기서 테스트 대역은 Mock이면서 Stub이지만, Mock이라고 불리지 Stub이라고 불리진 않는다.

이는 Mock이라는 사실이 Stub이라는 사실보다 중요하기 때문에 대체로 Mock이라고 불린다.

5.1.5. Mock과 Stub은 어떻게 명령과 조회에 관련되어있는가?

원제 : How mocks and stubs relate to commands and queries

Mock과 Stub의 개념은 명령 조회 분리(CQS : Command Query Separation) 원칙과 관련이 있다.

CQS 원칙에 따르면 모든 메서드는 명령 혹은 조회의 역할을 수행해야만 하며, 이 두 가지 역할을 동시에 수행해서는 안된다.

Figure 5.3

위의 그림과 같이 명령은 부작용을 일으키고 어떤 값도 반환하지 않는 메서드로 여기서 부작용은 객체의 상태 변경, 파일 시스템 내 파일 변경 등이 있다.

반대로 조회는 부작용을 일으키지 않고 값을 반환하는 메서드이다.

이 원칙을 따르는 경우 메서드가 부작용을 일으킨다면 반환 타입이 없는지 확인해야 하고, 값을 반환한다면 부작용이 없는지를 확인해야 한다.

대체로 명령을 대체하는 테스트 대역은 Mock이며, 조회를 대체하는 테스트 대역은 Stub이다.

아래 스니펫을 살펴보자.

1
2
3
4
5
var mock = new Mock<IEmailGateway>();
mock.Verify(x => x.SendGreetingsEmail("user@email.com"));

var stub = new Mock<IDatabase>();
stub.Setup(x => x.GetNumberOfUsers()).Returns(10);

위의 mock은 이메일을 보내는 메서드로 부작용이 존재할 수 있으나, 반환값이 없으므로 Mock으로 작성되었으며,

아래의 stub은 부작용은 없으나 반환값이 존재하므로 Stub으로 작성되었음을 확인할 수 있다.

5.2. 식별가능한 동작과 구현 세부 사항

원제 : Observable behavior vs. implementation details

Mock과 테스트 취약성 간의 연관성을 찾기 위한 다음 단계로, 이 취약성을 일으키는 원인을 알아보자.

테스트 취약성은 좋은 단위 테스트의 두 번째 특성이 리팩토링 내성에 해당한다.

따라서 테스트가 단위 테스트 영역에 존재하고, 엔드 투 엔드 테스트의 범주로 바뀌지 않는 한 리팩토링 내성을 최대한 활용하는 것이 좋다.

다시 되짚어보면, 리팩토링 내성을 깎아먹는 것은 거짓 양성의 발생이고, 거짓 양성의 발생은 구현 세부 사항과의 결합이 원인이다.

이 결합을 끊기 위해선 최종 결과만을 검증하고 구현 세부 사항과의 커플링을 끊어버려야한다는 것도 이미 알고 있다.

결과적으로 테스트는 “어떻게” 가 아니라 “무엇에” 중점을 두어야 한다.

그렇다면 구현 세부 사항은 정확히 무엇이며, 식별가능한 동작과는 어떻게 다를까?

5.2.1. 식별가능한 동작은 공개 API와 같지않다.

원제 : Observable behavior is not the same as a public API

모든 제품 코드는 두 가지 범주로 분류할 수 있다.

  1. 공개 API 또는 비공개 API
  2. 식별가능한 동작 또는 구현 세부 사항

이 중 식별가능한 동작과 내부 구현 세부 사항에는 미묘한 차이가 있다.

만약 코드가 시스템에서 식별가능한 동작이라면 다음 중 하나에 해당되어야 한다.

  1. operation : 클라이언트가 목표를 달성하는 데 도움이 되는 연산을 노출해야 한다. 연산은 계산을 수행하거나 부작용을 초래하거나 둘 다 해당되는 메서드이다.
  2. state : 클라이언트가 목표를 달성하는 데 도움이 되는 상태를 노출해야 한다. 여기서 상태는 시스템의 현재 상태를 말한다.

반대로 구현 세부 사항은 위 두 가지에 모두 해당되지않는다.

코드가 식별가능한 동작인지의 여부는 해당 클라이언트가 누구인지, 그리고 해당 클라이언트의 목표가 무엇인지에 달려있다.

식별간으한 동작이 되려면 코드가 이러한 목표 중 하나에라도 직접적인 관계가 있어야 한다.

이상적으로는 시스템의 공개 API는 식별가능한 동작과 일치해야 하며, 모든 구현 세부 사항은 클라이언트에게 노출되지 않아야 한다.

이를 충족하는 경우, 아래 그림과 같이 API 설계가 잘된 시스템이라고 볼 수 있다.

Figure 5.4

그러나 종종 시스템의 공개 API가 식별가능한 동작의 범위를 넘어 구현 세부 사항을 노출하는 경우가 있다.

이때 시스템의 구현 세부 사항은 공개 API로 유출된다.

Figure 5.5

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

원제 : Leaking implementation details: An example with an operation

구현 세부 사항이 공개 API로 유출되는 케이스를 예제로 살펴보도록 하자.

아래 예제에는 User 클래스가 있고, 이 클래스는 Name 속성과 NomalizeName() 메서드로 구성된 공개 API가 존재한다.

제약 조건으로는 사용자 이름이 50자를 초과해서는 안되며, 초과하는 경우 초과하는 부분을 잘라내는 동작을 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class User {
public string Name { get; set; }

public string NormalizeName(string name) {
string result = (name ?? "").Trim();
if (result.Length > 50)
return result.Substring(0, 50);
return result;
}
}

public class UserController {
public void RenameUser(int userId, string newName) {
User user = GetUserFromDatabase(userId);
string normalizedName = user.NormalizeName(newName);
user.Name = normalizedName;
SaveUserToDatabase(user);
}
}

UserController 클래스가 여기서 클라이언트의 역할을 하며, RenameUser() 메서드에서 User 클래스를 사용하다.

이 메서드의 목표는 사용자의 이름을 변경하는 것이다.

Name 속성은 이름을 변경하기 위해 setter를 노출하고 있어서 조건에 충족되나 NormalizeName()는 굳이 노출할 필요가 없는 설계이다.

Figure 5.6

좀 더 좋은 설계로 바꾸려면 NormalizeName()를 감추고 내부적으로 호출해야 한다.

아래는 개선한 코드를 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class User {
private string _name;
public string Name {
get => _name;
set => _name = NormalizeName(value);
}

private string NormalizeName(string name) {
string result = (name ?? "").Trim();
if (result.Length > 50)
return result.Substring(0, 50);
return result;
}
}

public class UserController {
public void RenameUser(int userId, string newName) {
User user = GetUserFromDatabase(userId);
user.Name = newName;
SaveUserToDatabase(user);
}
}

5.2.3. 잘 설계된 API와 캡슐화

원제 : Well-designed API and encapsulation

잘 설계된 API를 유지보수하는 것은 캡슐화 개념과 관련이 있다.

캡슐화는 항상 참이어야하는 불변성을 유지하기 위한 장치로 해석하는 것이다.

불변성 위반은 구현 세부 사항의 노출을 야기하고, 반대로 구현 세부 사항의 노출을 불변성 위반을 야기한다.

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

참고 마틴 파울러 - Tell Don’t Ask

5.2.4. 구현 세부 사항의 유출 : 상태의 예시

원제 : Leaking implementation details: An example with state

이번엔 상태를 노출되는 케이스에 대해서 알아보자.

먼저 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MessageRenderer : IRenderer {
public IReadOnlyList<IRenderer> SubRenderers { get; }

public MessageRenderer() {
SubRenderers = new List<IRenderer> {
new HeaderRenderer(),
new BodyRenderer(),
new FooterRenderer()
};
}

public string Render(Message message) {
return SubRenderers
.Select(x => x.Render(message))
.Aggregate("", (str1, str2) => str1 + str2);
}
}

속성의 SubRendererspublic으로 유지되고 있다.

이 클래스의 목표가 “Rendering” 임을 생각해보면 불필요한 노출이 발생하고 있는 것으로 구현 세부 사항이 노출되고 있는 것이다.

SubRenderersprivate로 감춘다면 테스트가 식별가능한 동작을 검증하는 것 외에는 다른 선택지를 주지않으므로 리팩토링 내성도 좋아지는 효과가 생긴다.

구분 식별가능한 동작 구현 세부 사항
공개 좋음 나쁨
비공개 해당 없음 좋음

5.3. Mock과 테스트 취약성 과의 관계

원제 : The relationship between mocks and test fragility

이번엔 육각형 아키텍쳐(hexagonal architecture), 내부 통신과 외부 통신의 차이점, 목과 테스트 취약성 간의 관계를 알아보자.

5.3.1. 육각형 아키텍쳐의 정의

원제 : Defining hexagonal architecture

전형적인 애플리케이션은 아래 그림과 같이 도메인과 애플리케이션 서비스를 두 레이어로 구성된다.

Figure 5.8

애플리케이션 서비스 계층은 도메인 계층 위에 있으며 외부 환경과의 통신을 조정한다.

예를 들어 RESTful API 기반의 애플리케이션은 모든 API 요청이 애플리케이션 서비스 계층에 먼저 도달하게 된다.

이 계층은 도메인 클래스와 프로세스 외부 의존성 간의 작업을 조정한다.

아래는 애플리케이션 서비스에 대한 조정의 예시다.

  • 데이터베이스를 조회하고 해당 데이터로 도메인 클래스 인스턴스 구체화
  • 해당 인스턴스에 연산 호출
  • 결과를 데이터베이스에 다시 저장

애플리케이션 서비스 계층과 도메인 계층의 조합은 육각형을 형성하며, 이 육각형은 애플리케이션 자체를 나타낸다.

애플리케이션은 또 다른 애플리케이션과 소통할 수 있으며 다른 애플리케이션도 역시 육각형으로 나타낼 수 있다.

아래 그림을 참고하자.

Figure 5.9

이 육각형 아키텍쳐라는 용어는 앨리스터 코오번(Alistair Cockburn) 이 처음 소개했으며, 아래 세 가지 중요한 지침을 강조한다.

참고 앨리스터 코오번(Alistair Cockburn)이 창시한 소프트웨어 방법론이 Agile 이다.

1. 도메인 계층과 애플리케이션 서비스 계층 간의 관심사의 분리
비즈니스 로직은 애플리케이션의 가장 중요한 부분으로, 도메인 계층은 비즈니스 로직에 대해서만 책임을 져야한다.
즉 외부 애플리케이션과의 통신이나 데이터 검색등은 애플리케이션 서비스에만 귀속되어야 하는 것이다.

2. 애플리케이션 내부 통신
육각형 아키텍쳐는 애플리케이션 서비스 계층에서 도메인 계층으로 흐르는 단방향 의존성 흐름을 규정한다.
도메인 계층 내부 클래스는 도메인 계층 내부 클래스끼리 서로 의존하고, 애플리케이션 서비스 계층의 클래스는 의존하지 않는다.

3. 애플리케이션 간의 통신
외부 애플리케이션은 애플리케이션 서비스 계층에 있는 공통 인터페이스를 통해 해당 애플리케이션에 연결되며, 직접적으로 도메인 계층에 연결되지 않는다.


이처럼 계층을 분리하게 되면 아래와 같이 프랙탈 구조를 이루게 된다.

Figure 5.10

위의 예제를 다시 가져와보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class User {
private string _name;
public string Name {
get => _name;
set => _name = NormalizeName(value);
}

private string NormalizeName(string name) {
string result = (name ?? "").Trim();
if (result.Length > 50)
return result.Substring(0, 50);
return result;
}
}

public class UserController {
public void RenameUser(int userId, string newName) {
User user = GetUserFromDatabase(userId);
user.Name = newName;
SaveUserToDatabase(user);
}
}

여기서 UserController가 애플리케이션 서비스를, User가 도메인 계층의 역할을 수행하게 된다.

5.3.2. 시스템 내 통신 vs 시스템 간 통신

원제 : Intra-system vs. intersystem communications

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

시스템 내부 통신은 애플리케이션 내 클래스 간의 통신을 말하며, 시스템 간 통신은 애플리케이션이 다른 애플리케이션과 통신하는 것을 말한다.

Figure 5.11

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

따라서 도메인 클래스 간의 협력을 클라이언트의 목표와 직접적인 관계가 없다고 볼 수 있고, 이와 결합한 테스트는 필연적으로 취약해질 수 밖에 없다.

반면 시스템 외부 환경과 통신하는 방식은 전체적으로 해당 시스템의 식별가능한 동작을 나타낸다.

Figure 5.12

Mock을 사용하면 시스템과 외부 애플리케이션 간의 통신 패턴을 확인할 때 유용하다.

5.3.3. 시스템 내 통신 vs 시스템 간 통신 : 예시

원제 : Intra-system vs. inter-system communications: An example

시스템 내부 통신과 시스템 간 통신의 차이점을 설명하기 위해 앞서 다루었던 하나의 예제를 살펴보자.

이 예제는 CustomerStore 클래스로 구성되어있고 아래오 같은 비즈니스 유즈케이스를 가지고 있다.

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

그리고 이 애플리케이션은 사용자 인터페이스가 없는 API라고 가정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CustomerController {
public bool Purchase(int customerId, int productId, int quantity) {
Customer customer = _customerRepository.GetById(customerId);
Product product = _productRepository.GetById(productId);

bool isSuccess = customer.Purchase(_mainStore, product, quantity);

if (isSuccess) {
_emailGateway.SendReceipt(customer.Email, product.Name, quantity);
}
return isSuccess;
}
}

CustomerController 클래스는 도메인 클래스인 CustomerStore와 외부 애플리케이션 간의 작업을 조정하는 애플리케이션 서비스를 담당한다.

시스템 내부 통신은 CustomerStore간의 통신을 의미하고

시스템 간 통신은 CustomerController 클래스와 외부 서드파티와 이메일 게이트웨이 간의 통신이다.

이때 외부 통신인 SMTP 서비스에 대한 호출을 테스트할때 Mock으로 작업하는 것이 좋다.

이는 리팩톨이 후에도 외부 통신에 대한 유형을 그대로 유지해주어 테스트 취약성을 야기하지 않기 떄문이다.

아래는 Mock을 사용하는 타당한 테스트 코드의 예제이다.

1
2
3
4
5
6
7
8
9
10
[Fact]
public void Successful_purchase() {
var mock = new Mock<IEmailGateway>();
var sut = new CustomerController(mock.Object);

bool isSuccess = sut.Purchase(customerId: 1, productId: 2, quantity: 5);

Assert.True(isSuccess);
mock.Verify(x => x.SendReceipt("customer@email.com", "Shampoo", 5), Times.Once);
}

여기서 isSuccess 플래그는 외부 클라이언트에서도 노출되므로 검증이 필요하다.

이 플래그를 테스트하는 데는 Mock이 필요없고 간단한 비교만으로 충분하다.

이번엔 Customer 클래스와 Store 클래스 간의 통신에 Mock을 사용한 테스트 예제를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
[Fact]
public void Purchase_succeeds_when_enough_inventory() {
var storeMock = new Mock<IStore>();
storeMock
.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(true);
var customer = new Customer();

bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);

Assert.True(success);
storeMock.Verify(x => x.RemoveInventory(Product.Shampoo, 5), Times.Once);
}

Customer 클래스에서 Store로의 메서드 호출을 애플리케이션 내부에서 일어나므로 클라이언트의 목표와는 관련이 없다.

이 두 도메인 클래스는 중요한 몇몇 메서드에 대해서만 테스트하므로 구현 세부 사항에 해당된다.

5.4. 단위 테스트의 고전파 vs 런던파 : 다시 논의해보기

원제 : The classical vs. London schools of unit testing, revisited

앞선 포스팅에서 다루었던 단위 테스트의 고전파와 런던파간의 차이점을 리마인드해보자.

구분 격리 주체 단위의 크기 테스트 대역 사용 대상
런던파 단위 단일 클래스 불변 의존성 외 모든 의존성
고전파 단위 테스트 단일 클래스 또는 클래스 테스트 공유 의존성

런던파는 불변 의존성을 제외한 모든 의존성에 Mock 사용을 권장하며, 시스템 내 통신과 시스템 간 통신을 구분하지 않는다.

따라서 런던파 스타일의 테스트는 시스템 내부와 시스템 간 통신에 차이점이 없다.

이렇게 무분별하게 Mock을 사용하게 되는 경우, 종종 구현 세부 사항과 결합하여 리팩토링 내성을 낮추는 결과를 나타내기도 한다.

거듭 강조하지만 리팩토링 내성이 저하되면 해당 테스트는 더 이상 가치가 있다고 볼 수 없다.

고전파는 테스트 간의 공유하는 의존성만 교체하는 것을 권장하므로 이 문제에 있어 좀 더 유리한 입장이다.

다만 고전파 역시 Mock의 사용을 권장하므로 시스템 간 통신에 대해서는 이상적이지 않다고 볼 수 있다.

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

원제 : Not all out-of-process dependencies should be mocked out

프로세스 외부 의존성과 Mock을 설명하기 전에 의존성 유형에 대해서 다시 살펴보자.

공유의존성(shared dependency)
테스트간에 공유되고 서로의 결과에 영향을 미칠수 있는 수단을 제공하는 의존성을 뜻한다.
가장 대표적인 예시는 정적 가변 필드(static mutable field)로, 동일한 프로세스 내에 위치하는 모든 단위 테스트에서 볼 수 있다.
비슷한 사례로 데이터베이스나 파일시스템도 공유 의존성의 예시가 될 수 있다.

비공개 의존성(private dependency)
말 그대로 테스트간에 공유되지않는 의존성을 뜻한다.

프로세스 외부 의존성(out-of-process dependency)
애플리케이션의 실행 프로세스 외부에서 실행되는 의존성을 뜻한다.
프로세스 외부 의존성은 아직 메모리에 없는 데이터에 대한 프록시로, 대부분 공유 의존성에 해당된다.
환경에 따라서 데이터베이스는 프로세스 외부 의존성이면서 비공개 의존성의 특징을 가지기도 한다.

참고 028. (Unit Test Principles) 2. 단위 테스트란 무엇인가

고전파에서는 공유 의존성을 피할 것을 권장한다.

이는 테스트가 실행 컨테스트를 서로 방해하고, 결국 병렬 처리를 할 수 없기 때문이다.

참고 테스트를 병렬적, 순차적 또는 임의의 순서로 실행할 수 있는 것을 테스트 격리(test isolation) 라고 부른다.

공유 의존성이 프로세스 외부에 있는 것이 아니면 각 테스트 실행시 해당 의존성을 새 인스턴스로 써서 재사용ㅇ르 피하기 쉽다.

반면 공유 의존성이 프로세스 외부에 있으면 테스트가 더욱 복잡해진다.

각 테스트 실행 전마다 데이터베이스나 메시지 버스를 새로 준비한다면 테스트 스위트가 느려져 빠른 피드백을 보장받을 수 없게 될 것이다.

이를 위해 공유 의존성을 테스트 대역인 Mock과 Stub으로 교체하는 것이 일반적인 접근 방법이다.

그러나 모든 프로세스 외부 의존성을 Mock으로 치환할 필요는 없다.

프로세스 외부 의존성이 애플리케이션을 통해서만 접근할 수 있다면 이러한 통신 방식은 시스템에서 식별할 수 있는 동작이 아닐 것이다.

결론적으로 외부에서 관찰할 수 없는 프로세스 외부 의존성은 애플리케이션 일부로 취급되며, 구현 세부 사항이 되어 버린다.

따라서 Mock으로 검증할 필요가 없는 케이스가 되어버린다.

Figure 5.12

반면 애플리케이션이 외부 시스템에 대한 프록시 같은 역할을 하고 클라이언트가 직접 접근할 수 없으면 하위 호환성 요구 사항은 사라진다.

완전히 통제권을 가진 프로세스 외부 의존성에 Mock을 적용하면 깨지기 쉬운 테스트로 이어지게 된다.

즉 외부 의존성과 애플리케이션이 마치 하나의 시스템인 것처럼 처리해야한다.

이를 위한 좀 더 자세한 방법은 추후 포스팅에서 다루도록 하겠다.

5.4.2. Mock을 사용한 동작의 검증

원제 : Using mocks to verify behavior

상술했듯 각 개별 클래스가 이웃 클래스와 소통하는 방식은 식별가능한 동작과 아무런 관계가 없는 구현 세부 사항이다.

이 클래스 간의 통신을 검증하는 것은 너무 세밀한 동작을 검증하는 것으로, 사용자의 동작과는 거리가 멀다.

Mock은 애플리케이션의 경계를 벗어나는 상호 작용을 검증할 때와, 이 상호 작용의 부작용이 외부에서 관찰되는 경우에만 그 의미가 있다.