035. (Unit Test Principles) 9. Mock 처리에 대한 모범 사례

9. Mock 처리에 대한 모범 사례

원제 : Mocking best practices

여태까지의 포스팅을 통해 Mock은 테스트 대상 시스템과 의존성 간의 상호 작용을 모방하고 검사하는 데 도움이되는 테스트 대역이며,

프로세스 외부 의존성 중 비관리 의존성에만 적용해야하는 것에 대해 알게 되었다.

참고
031. (Unit Test Principles) 5. Mock과 테스트 취약성
034. (Unit Test Principles) 8. 왜 통합 테스트를 해야 하는가?

이번 포스팅에서는 Mock에 대한 리팩토링 내성과 회귀 방지를 최대화하여 최대 가치를 가진 통합 테스트를 개발하는 방법에 대해서 알아보자.

먼저 일반적인 Mock의 사용법과 이에 따른 단점을 살펴보고, 이 단점을 극복할 수 있는 방법에 대해서 살펴보도록 하자.

9.1. Mock의 가치를 극대화하기

원제 : Maximizing mocks’ value

Mock을 비관리 의존성에만 사용할 수 있도록 제한하는 것은 Mock의 가치를 극대화하기위한 첫 번째 단계일 뿐이다.

이전 포스팅에서 사용했던 고객 관리 시스템과 통합 테스트를 다시 살펴보고, Mock에 대한 테스트를 개선하는 방법을 찾아보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class UserController {
private readonly Database _database;
private readonly EventDispatcher _eventDispatcher;

public UserController(
Database database,
IMessageBus messageBus,
IDomainLogger domainLogger) {

_database = database;
_eventDispatcher = new EventDispatcher(messageBus, domainLogger);
}

public string ChangeEmail(int userId, string newEmail) {
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);

string error = user.CanChangeEmail();
if (error != null)
return error;

object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);

_database.SaveCompany(company);
_database.SaveUser(user);
_eventDispatcher.Dispatch(user.DomainEvents);

return "OK";
}
}

위의 예제에는 EventDispatcher라는 새로운 클래스가 도입되었다.

EventDispatcher 클래스는 도메인 모델에서 생성된 도메인 이벤트를 비관리 의존성에 대한 호출로 변환해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class EventDispatcher {
private readonly IMessageBus _messageBus;
private readonly IDomainLogger _domainLogger;

public EventDispatcher(
IMessageBus messageBus,
IDomainLogger domainLogger) {

_domainLogger = domainLogger;
_messageBus = messageBus;
}

public void Dispatch(List<IDomainEvent> events) {
foreach (IDomainEvent ev in events) {
Dispatch(ev);
}
}

private void Dispatch(IDomainEvent ev) {
switch (ev) {
case EmailChangedEvent emailChangedEvent:
_messageBus.SendEmailChangedMessage(
emailChangedEvent.UserId,
emailChangedEvent.NewEmail
);
break;
case UserTypeChangedEvent userTypeChangedEvent:
_domainLogger.UserTypeHasChanged(
userTypeChangedEvent.UserId,
userTypeChangedEvent.OldType,
userTypeChangedEvent.NewType
);
break;
}
}
}

마지막으로 통합 테스트 코드이다.

이 테스트는 모든 프로세스 외부 의존성을 거친다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
[Fact]
public void Changing_email_from_corporate_to_non_corporate() {

// Arrange
var db = new Database(ConnectionString);
User user = CreateUser("user@mycorp.com", UserType.Employee, db);
CreateCompany("mycorp.com", 1, db);
// Sets up the mocks
var messageBusMock = new Mock<IMessageBus>();
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBusMock.Object, loggerMock.Object);

// Act
string result = sut.ChangeEmail(user.UserId, "new@gmail.com");

// Assert
Assert.Equal("OK", result);

object[] userData = db.GetUserById(user.UserId);
User userFromDb = UserFactory.Create(userData);
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);

object[] companyData = db.GetCompany();
Company companyFromDb = CompanyFactory.Create(companyData);
Assert.Equal(0, companyFromDb.NumberOfEmployees);
// Verifies the interactions with the mocks
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once
);
loggerMock.Verify(
x => x.UserTypeHasChanged(user.UserId,UserType.Employee,UserType.Customer),
Times.Once
);
}

이 통합테스트는 비관리 의존성인 IMessageBusIDomainLogger를 Mock으로 처리하였다.

먼저 IMessageBus에 대해서 살펴보자.

9.1.1. 시스템의 끝에서 상호 작용 검증

원제 : Verifying interactions at the system edges

바로 위에서 다룬 통합 테스트에서 사용한 IMessageBus의 Mock 처리가 이상적이지 않은 이유는 무엇일까?

그 이유는 messageBusMock으로 Mocking한 IMessageBus 인터페이스가 시스템의 끝에 있지않기 때문이다.

이는 Mock을 사용할 때는 시스템의 끝에서 비관리 의존성과의 상호 작용을 검증하는 지침을 위배하는 것이다.

먼저 IMessageBus의 구현을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface IMessageBus {
void SendEmailChangedMessage(int userId, string newEmail);
}

public class MessageBus : IMessageBus {
private readonly IBus _bus;

public void SendEmailChangedMessage(int userId, string newEmail) {
_bus.Send("Type: USER EMAIL CHANGED; " +
$"Id: {userId}; " +
$"NewEmail: {newEmail}"
);
}
}

public interface IBus {
void Send(string message);
}

MessageBus 클래스와 IBus 인터페이스는 둘 다 코드베이스에 속해있다.

IBus는 메시지 버스 SDK의 wrapper로 임의의 텍스트 메시지를 별도의 자격 증명없이 전송할 수 있는 깔끔한 인터페이스라고 가정한다.

IMessageBus는 도메인과 관련된 메시지를 정의하여, 모든 메시지를 한 곳에 모으고 애플리케이션에서 재사용할 수 있도록 해준다.

IBusIMessageBus 인터페이스를 통합할 수는 있지만 두 가지 책임을 가지게 되므로 이는 어디까지나 차선책에 불과하다.

육각형 아키텍쳐 입장에서 IBusIMessageBus를 나타내보면 아래와 같다.

Figure 9.1

IMessageBus 대신 IBus를 Mocking하는 경우를 생각해보자.

IBus는 시스템의 끝에 위치하기에 Mock으로 처리시 회귀 방지를 극대화할 수 있을 것이다.

즉 비관리 의존성과 통신하는 마지막 타입을 Mock으로 처리하면 통합 테스트가 거치는 클래스의 수가 증가하므로 회귀 방지가 극대화되는 것이다.

참고 이 지침은 EventDispatcher를 Mock으로 처리하고 싶지않은 이유이기도 하다.

만약 IMessageBusIBus로 바꾼 다면 통합 테스트는 아래와 같이 변경될 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[Fact]
public void Changing_email_from_corporate_to_non_corporate() {

// Arrange
var db = new Database(ConnectionString);
User user = CreateUser("user@mycorp.com", UserType.Employee, db);
CreateCompany("mycorp.com", 1, db);
// Sets up the mocks
var busMock = new Mock<IBus>();
var messageBus = new MessageBus(busMock.Object); // Uses a concrete class instead of the interface
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);

// Act
string result = sut.ChangeEmail(user.UserId, "new@gmail.com");

// Assert
Assert.Equal("OK", result);

object[] userData = db.GetUserById(user.UserId);
User userFromDb = UserFactory.Create(userData);
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);

object[] companyData = db.GetCompany();
Company companyFromDb = CompanyFactory.Create(companyData);
Assert.Equal(0, companyFromDb.NumberOfEmployees);
// Verifies the actual message sent to the bus
busMock.Verify(
x => x.Send(
"Type: USER EMAIL CHANED; " +
$"Id: {user.UserId}; " +
"NewEmail: new@gmail.com",)
Times.Once
);
loggerMock.Verify(
x => x.UserTypeHasChanged(user.UserId,UserType.Employee,UserType.Customer),
Times.Once
);
}

이제 IMessageBus 인터페이스가 아닌 messageBus 객체를 통해 Mocking 외에 용도가 없던 IMessageBus 인터페이스를 소거했음을 알 수 있다.

또한 메시지 버스로의 전송 방식도 변경되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// AS-IS - Verifies the interactions with the mocks
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once
);

// TO-BE - Verifies the actual message sent to the bus
busMock.Verify(
x => x.Send(
"Type: USER EMAIL CHANED; " +
$"Id: {user.UserId}; " +
"NewEmail: new@gmail.com",)
Times.Once
);

기존에 사용자 정의 클래스에 대한 호출을 검증하는 것과, 외부 시스템에 전송한 실제 텍스트가 다른 것을 볼 수 있다.

외부 시스템은 애플리케이션으로부터 텍스트 메시지를 수신하고 MessageBus와 같은 클래스를 호출하지 않기 때문에, 시스템의 바깥에서 식별할 수 있는 유일한 부작용으로 볼 수 있다.

결과적으로 시스템의 끝에서 상호 작용을 확인함으로서 회귀 방지와 리팩토링 내성이 향상되는 것이다.

이후 리팩토링을 수행하더라도 메시지 구조를 유지하는 한 해당 테스트는 성공할 것이며, 단위 테스트에 비해 통합 테스트와 종단간 테스트가 리팩토링 내성이 우수한 것과 같은 매커니즘을 지니게 된다.

9.1.2. Mock을 Spy로 대체하기

원제 : Replacing mocks with spies

Spy는 Mock처럼 동일한 목적을 수행하는 테스트 대역이다.

단지 프레임워크의 도움을 받아 생성하는 Mock과 달리 직접 작성해야한다는 차이점이 있을 뿐이다.

시스템의 끝에 있는 클래스의 경우는 Mock보다는 Spy가 더 나은 대안이 되기도 한다.

Spy는 검증 단계에서 코드를 재사용함으로써, 테스트의 크기를 줄이고 가독성을 향상 시킨다.

아래는 IBus 위에서 작동하는 Spy의 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public interface IBus {
void Send(string message);
}


public class BusSpy : IBus {
// Stores all sent messages locally
private List<string> _sentMessages = new List<string>();

public void Send(string message) {
_sentMessages.Add(message);
}

public BusSpy ShouldSendNumberOfMessages(int number) {
Assert.Equal(number, _sentMessages.Count);
return this;
}

// Asserts that the message has been sent
public BusSpy WithEmailChangedMessage(int userId, string newEmail) {
string message = "Type: USER EMAIL CHANGED; " +
$"Id: {userId}; " +
$"NewEmail: {newEmail}";

Assert.Contains(
_sentMessages, x => x == message);
return this;
}
}

아래는 BusSpy를 적용한 새로운 통합 테스트 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
[Fact]
public void Changing_email_from_corporate_to_non_corporate() {

// Arrange
var db = new Database(ConnectionString);
User user = CreateUser("user@mycorp.com", UserType.Employee, db);
CreateCompany("mycorp.com", 1, db);

// Sets up the spy and mocks
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);

// Act
string result = sut.ChangeEmail(user.UserId, "new@gmail.com");

// Assert
Assert.Equal("OK", result);

object[] userData = db.GetUserById(user.UserId);
User userFromDb = UserFactory.Create(userData);
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);

object[] companyData = db.GetCompany();
Company companyFromDb = CompanyFactory.Create(companyData);
Assert.Equal(0, companyFromDb.NumberOfEmployees);
// Verifies the actual message sent to the bus
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
loggerMock.Verify(
x => x.UserTypeHasChanged(user.UserId,UserType.Employee,UserType.Customer),
Times.Once
);
}

BusSpy에서 제공하는 플루언트 인터페이스를 통해 메시지 버스와의 상호 작용을 검증하는 코드의 간결함과 가독성을 확보할 수 있게되었다.

헌데, 기존의 IMessageBus와 별다른 차이점이 없어보인다.

이는 겉모습만 그런 것이고 BusSpy는 테스트 코드 영역에, MessageBus는 제품 코드 영역에 속한다는 차이점이 있다.

테스트에서 검증 구절을 작성할 때 제품 코드에 의존하지 않아야함을 생각하면 매우 큰 차이임을 알 수 있다.

9.2. Mock 처리에 대한 모범 사례

원제 : Mocking best practices

지금까지 Mock을 처리하는 모범 사례로 두 가지를 배웠다.

  1. 비관리 의존성에만 Mock 적용하기(Applying mocks to unmanaged dependencies only)
  2. 시스템 끝에 있는 의존성에 대해 상호 작용 검증 (Verifying the interactions with those dependencies at the very edges of your system)

이제 나머지 세 가지 모범 사례에 대해서 알아보자.

  1. 통합 테스트에서만 Mock을 사용하고 단위 테스트에서는 사용하지 않기(Using mocks in integration tests only, not in unit tests)
  2. 항상 Mock의 호출 수를 확인하기(Always verifying the number of calls made to the mock)
  3. 보유한 타입에 대해서만 Mock으로 처리하기(Mocking only types that you own)

9.2.1. Mock은 통합 테스트만을 위한 것이다.

원제 : Mocks are for integration tests only

세번째 사례인 통합 테스트에서만 Mock을 사용하고 단위 테스트에서는 사용하지 않기 에 대해서 알아보자.

이 지침을 지키는 것에는 복잡한 코드를 처리하는 도메인 모델과 통신을 처리하는 컨트롤러의 두 계층의 분화가 내재되어있다.

이는 도메인 모델 계층에 대한 테스트는 단위 테스트로 수행하고, 컨트롤러 계층은 통합 테스트를 수행해야 함을 뜻한다.

Mock은 비관리 의존성에 대해서만 대치해야 하며, 컨트롤러가 비관리 의존성을 처리하는 것임을 생각하면 된다.

9.2.2. 하나의 테스트가 하나의 Mock만 가질 필요는 없다.

원제 : Not just one mock per test

테스트를 작성하다보면 하나의 테스트는 하나의 Mock만 사용해야한다는 말을 들을 수도 있다.

이는 Mock이 두 개 이상인 경우 여러 검증을 하나의 테스트에서 수행할 수 있는 가능성이 있기 때문인데, 이는 “단위” 라는 용어에서 오는 오해이다.

단위 테스트의 “단위”는 코드의 단위가 아니라 동작의 단위임을 명심하자.

동작의 단위와 이를 구현하는 데 필요한 코드의 양은 관계가 없다.

Mock을 사용할 때도 동일한 원칙이 적용되므로, 하나의 동작을 검증하는 데 Mock의 수는 관계가 없다.

9.2.3. 호출 횟수 검증하기

원제 : Verifying the number of calls

비관리 의존성과의 통신에 대해서는 아래 두 가지 항목을 확인하는 것이 중요하다.

  • 예상하는 호출이 있는가?(The existence of expected calls)
  • 예상치 못한 호출은 없는가?(The absence of unexpected calls)

위 요구사항은 비관리 의존성과 하위 호환성을 지켜야하는 데서 비롯된다.

애플리케이션은 외부 시스템이 예상하는 메시지를 생략해선 안되며, 예상치 못한 메시지를 생성해서도 ㄷ안된다.

즉, 호환성은 양방향이어야 한다.

따라서 테스트 대상 시스템이 다음과 같이 메시지를 전송하는지 확인하는 것만으로는 충분하지 않다.

1
2
3
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com")
);

이 메시지가 정확히 한 번만 전송되는지도 검증해야 한다.

1
2
3
4
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once // HERE
);

위에서 다룬 예제인 BusSpy도 아래와 같이 호출 횟수를 검증할 수 있다.

1
2
3
busSpy
.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");

9.2.4. 보유 타입만 Mock으로 처리하기

원제 : Only mock types that you own

마지막 지침은 보유한 타입에 한정해서 Mock으로 처리하라는 것이다.

스티브 프리먼과 냇 프라이스가 주장하는 바에 따르면, 서드파티 라이브러리 위에 항상 어댑터를 작성하고 기본 타입대신 해당 어댑터를 Mock으로 처리해야한다고 말한다.

참고 위의 주장은 테스트 주도 개발로 배우는 객체 지향 설계와 실천 서적에 나온다.

  • 서드파티 코드의 작동 방식에 대해 깊이 이해하지 못하는 경우가 많다.
  • 해당 코드가 이미 내장 인터페이스를 제공하더라도 Mock으로 처리한 동작이 실제 외부 라이브러리와 일치해야하는 지 확인해야 하므로, 해당 인터페이스를 Mock으로 처리하는 것은 위험하다.
  • 서드파티 코드의 기술 세부 사항까지는 꼭 필요하지 않기에 이를 추상하기 위하여 어댑터를 작성하고, 애플리케이션의 관점에서 라이브러리와의 관계를 정의해야 한다.

실제로 어댑터는 코드와 외부 환경 사이에서 일종의 손상 방지 계층으로 동작한다.

어댑터를 이용하면

  • 기본 라이브러리의 복잡성을 추상화하고
  • 라이브러리에서 필요한 기능만 노출하며
  • 프로젝트 도메인 언어를 사용해 수행할 수 있다.

즉 프로젝트가 “보유한 어댑터” 를 Mock의 대상으로 삼아 테스트를 수행하는 것이 좋다.