•
목은 테스트 대상 시스템과 의존성 간의 상호 작용을 모방하고 검사하는 데 도움이 되는 테스트 더블이다.
◦
관리 의존성이나 내부 의존성에 목을 사용하게 되면 리팩토링 내성이 약한 테스트가 된다.
•
이 장에서는 목을 리팩토링 내성과 회귀 방지를 최대화해서 최대 가치의 통합 테스트를 작성하는 방법에 대해서 알아보고 단점을 극복하는 방법에 대해 살펴본다.
1. 목의 가치를 극대화하기
•
비관리 의존성에만 목을 사용하게끔 제한하는 것도 중요하지만 이는 목의 가치를 극대화하기 위한 첫 단계일 뿐이다.
•
다음은 앞서 살펴본 예제의 변경된 내용이다.
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";
}
}
C#
복사
◦
위의 예제에는 EventDispatcher라는 새로운 클래스가 도입되었다. EventDispatcher 클래스는 도메인 모델에서 생성된 도메인 이벤트를 비관리 의존성에 대한 호출로 변환해준다.
•
다음은 이에 대한 통합 테스트 코드이다.
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
);
}
C#
복사
◦
이 통합 테스트는 비관리 의존성에 해당하는 IMessageBus와 IDomainLogger를 목으로 처리하였다.
1.
시스템의 끝에서 상호 작용 검증
•
위 통합 테스트에서 사용한 messageBusMock의 문제점은 IMessage 인터페이스가 시스템의 끝에 위치하지 않는다는 것이다.
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);
}
C#
복사
•
MessageBus와 IBus 인터페이스는 둘 다 프로젝트 코드에 해당한다.
•
IBus는 메시지 버스 SDK의 래퍼로 임의의 텍스트 메시지를 별도의 자격 증명없이 전송할 수 있는 깔끔한 인터페이스라고 가정하자.
•
IMeesageBus는 도메인과 관련된 메시지를 정의하고 모든 메시지를 한 곳으로 모아 어플리케이션에서 재사용할 수 있도록 해준다.
•
이렇게 되면 IMessageBus는 어느정도 도메인 로직을 가지게 되므로 시스템의 끝에 위치하고 있지 않는다.
•
IMessageBus 대신 IBus를 모킹하는 경우를 생각해보자. 즉 IBus는 시스템의 끝에 위치하기에 목으로 처리시 회귀 방지를 극대화할 수 있다.
•
만약 IMessageBus를 IBus로 대체한다면 통합 테스트는 아래와 같이 변경될 것이다.
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
);
}
C#
복사
•
결과적으로 시스템의 끝에서 상호작용을 확인함으로서 회귀 방지와 리팩토링 내성이 더 향상된다는 것이다.
•
이후 리팩토링을 수행하더라도 메시지 구조를 유지하는 한 해당 테스트는 성공할 것이며, 통합 테스트와 E2E 테스트가 리팩토링 내성이 우수한 것과 같은 매커니즘을 지니게 된다.
2.
목을 스파이로 대체하기
•
스파이는 목과 같은 목적(상호작용)을 수행하는 테스트 더블이다. 스파이는 수동으로 작성하는 반면에 목은 목 프레임워크의 도움을 받아 생성한다는 것이 유일한 차이점이다.
◦
종종 직접 작성한 목이라고도 한다.
•
시스템의 끝에 있는 클래스의 경우, 스파이가 목보다 낫다. 스파이는 검증 단계에서 코드를 재사용해 테스트 크기를 줄이고 다고성을 향상시킨다.
•
다음 예제는 IBus 위에서 동작하는 스파이다.
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;
}
}
C#
복사
•
아래는 IBus를 스파이로 대체한 버전의 통합 테스트다.
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
);
}
C#
복사
◦
약간 기능이 많이 필요한 Mock 객체에 대해 역할을 덕지덕지붙이지 않고 가독성 좋게 분리해낸 느낌이 들었다.
2. 목 처리에 대한 모범 사례
•
목을 처리하는 것과 관련해 지금까지 주요한 모범 사례 두 가지를 배웠다.
◦
비관리 의존성에만 모킹하기
◦
시스템 끝에 있는 의존성에 대해서 상호 작용을 검증하기
•
이 절에서는 나머지 모범 사례를 설명한다.
◦
통합 테스트에서만 목을 사용하고 단위 테스트에서는 하지 않기
◦
항상 목 호출 수를 확인하기
◦
보유 타입만 목으로 처리하기
1.
목은 통합 테스트만을 위한 것
•
도메인 모델에 대한 테스트는 단위 테스트 범주에 속하며 컨트롤러를 다루는 테스트는 통합 테스트다.
•
목은 비관리 의존성에만 해당하며 컨트롤러만 이러한 의존성을 처리하므로 통합 테스트의 컨트롤러를 테스트할 때만 목을 사용해야 한다.
2.
테스트당 목이 하나일 필요는 없음
•
테스트 당 목이 둘 이상인 경우, 한 번에 여러 가지를 테스트하고 있을 가능성이 있기 때문에 목을 하나만 두라는 지침을 들었을 수도 있다.
•
동작 단위를 검증하는 데 있어서 목의 갯수는 관계가 없다. 목의 수는 오직 운영에 참여하는 비관리 의존성 수에만 의존한다.
3.
호출 횟수 검증하기
•
비관리 의존성과의 통신에 있어서 다음 두 가지를 확인하는 것은 매우 중요하다.
◦
예상하는 호출이 있는가?
◦
예상치 못한 호출은 없는가?
•
이 요구 사항은 다시 비관리 의존성과 하위 호환성을 지켜야 하는 데서 비롯된다.
◦
호환성은 양방향이어야 한다. 즉 어플리케이션은 외부 시스템이 예상하는 메시지를 생략해서는 안 되며 예상치 못한 메시지도 생성해서는 안 된다.
4.
보유 타입만 목으로 처리하기
•
마지막 지침은 보유 타입만 목으로 처리하라는 것이다.
•
사용하고자 하는 라이브러리에 어댑터를 작성하고 기본 타입 대신 어댑터를 목으로 처리해야 한다.
◦
서드파티 코드의 작동 방식에 대해 깊이 이해하고 있지 않기 때문이다.
◦
해당 코드가 이미 내장 인터페이스를 제공하더라도 목으로 처리한 동작이 실제 외부 라이브러리와 일치하는지 확인해야 하므로 목으로 처리하는 것은 위험하다.
◦
서드파티 코드의 기술 세부 사항까지는 꼭 필요하지 않기에 어댑터는 이를 추상화하고 어플리케이션 관점에서 라이브러리와의 관계를 정의한다.
•
실제로 어댑터는 코드와 외부 환경 사이의 손상 방지 계층으로 동작한다.
◦
기본 라이브러리의 복잡성을 추상화하고
◦
라이브러리에서 필요한 기능만 노출하며
◦
프로젝트 도메인 언어를 사용해 수행할 수 있다.
•
라이브러리에 변경사항이 생길 가능성을 배제하지 말자.
요약
•
시스템 끝에서 비관리 의존성과의 상호 작용을 검증하라.
◦
이는 회귀 방지와 리팩토링 내성을 더 확보할 수 있게 해준다.
▪
내 생각엔 그냥 테스트하는 것이 더 나아보인다.. 무조건 구현이나 상속을 하는 것도 아니고 래핑해서 사용할 수도 있고..
•
스파이는 직접 작성한 수동 목이다.
◦
시스템 끝에 있는 클래스에 대해서는 스파이가 목보다 낫다. 검증 단계에서 코드를 재사용해 테스트 크기가 줄고 가독성이 개선된다.
•
검증문을 작성할 때 제품 코드에 의존하지 말라.
◦
테스트에서 별도의 리터럴과 상수 집합을 사용하는 것이 좋다. 필요하면 리터럴과 상수를 복제하라.
◦
테스트는 제품 코드와 독립적으로 검사점을 제공해야 한다.
•
목은 비관리 의존성만을 위한 것이고 이러한 의존성을 처리하는 코드는 협력자를 테스트하는 통합 테스트 뿐임으로 이때만 목을 적용해야 한다. 단위 테스트엔 적용하지 말라.
•
테스트에서 사용된 목의 수는 크게 문제되지 않는다.
•
목에 예쌍되는 호출이 있는지와 예상치 못한 호출이 존재하는 지 확인하라
•
보유 타입만 목으로 처리하라 비관리 의존성에 접근하는 서드파티 라이브러리 위에 어댑터를 두어라. 라이브러리에서 제공해주는 클래스 대신 어댑터를 모킹하라.