////
Search
Duplicate
♥️

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

목은 테스트 대상 시스템과 의존성 간의 상호 작용을 모방하고 검사하는 데 도움이 되는 테스트 더블이다.
관리 의존성이나 내부 의존성에 목을 사용하게 되면 리팩토링 내성이 약한 테스트가 된다.
이 장에서는 목을 리팩토링 내성과 회귀 방지를 최대화해서 최대 가치의 통합 테스트를 작성하는 방법에 대해서 알아보고 단점을 극복하는 방법에 대해 살펴본다.

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#
복사
이 통합 테스트는 비관리 의존성에 해당하는 IMessageBusIDomainLogger를 목으로 처리하였다.
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#
복사
MessageBusIBus 인터페이스는 둘 다 프로젝트 코드에 해당한다.
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.
보유 타입만 목으로 처리하기
마지막 지침은 보유 타입만 목으로 처리하라는 것이다.
사용하고자 하는 라이브러리에 어댑터를 작성하고 기본 타입 대신 어댑터를 목으로 처리해야 한다.
서드파티 코드의 작동 방식에 대해 깊이 이해하고 있지 않기 때문이다.
해당 코드가 이미 내장 인터페이스를 제공하더라도 목으로 처리한 동작이 실제 외부 라이브러리와 일치하는지 확인해야 하므로 목으로 처리하는 것은 위험하다.
서드파티 코드의 기술 세부 사항까지는 꼭 필요하지 않기에 어댑터는 이를 추상화하고 어플리케이션 관점에서 라이브러리와의 관계를 정의한다.
실제로 어댑터는 코드와 외부 환경 사이의 손상 방지 계층으로 동작한다.
기본 라이브러리의 복잡성을 추상화하고
라이브러리에서 필요한 기능만 노출하며
프로젝트 도메인 언어를 사용해 수행할 수 있다.
라이브러리에 변경사항이 생길 가능성을 배제하지 말자.

요약

시스템 끝에서 비관리 의존성과의 상호 작용을 검증하라.
이는 회귀 방지와 리팩토링 내성을 더 확보할 수 있게 해준다.
내 생각엔 그냥 테스트하는 것이 더 나아보인다.. 무조건 구현이나 상속을 하는 것도 아니고 래핑해서 사용할 수도 있고..
스파이는 직접 작성한 수동 목이다.
시스템 끝에 있는 클래스에 대해서는 스파이가 목보다 낫다. 검증 단계에서 코드를 재사용해 테스트 크기가 줄고 가독성이 개선된다.
검증문을 작성할 때 제품 코드에 의존하지 말라.
테스트에서 별도의 리터럴과 상수 집합을 사용하는 것이 좋다. 필요하면 리터럴과 상수를 복제하라.
테스트는 제품 코드와 독립적으로 검사점을 제공해야 한다.
목은 비관리 의존성만을 위한 것이고 이러한 의존성을 처리하는 코드는 협력자를 테스트하는 통합 테스트 뿐임으로 이때만 목을 적용해야 한다. 단위 테스트엔 적용하지 말라.
테스트에서 사용된 목의 수는 크게 문제되지 않는다.
목에 예쌍되는 호출이 있는지와 예상치 못한 호출이 존재하는 지 확인하라
보유 타입만 목으로 처리하라 비관리 의존성에 접근하는 서드파티 라이브러리 위에 어댑터를 두어라. 라이브러리에서 제공해주는 클래스 대신 어댑터를 모킹하라.