박종훈 기술블로그

목 처리에 대한 모범 사례 - 9장

9장 목 처리에 대한 모범 사례 단위테스트 (블라디미르 코리코프)


9장에서 다루는 내용 목의 가치를 극대화하기 목을 스파이로 교체하기 목 처리에 대한 모범 사례

목은 테스트 대상 시스템과 의존성 간의 상호 작용을 모방하고 검사 하는 데 도움이 되는 테스트 대역이다.

목은 비관리 의존성(외부 애플리케이션에서 식별할 수 있음)에만 적용해야 한다. 다른 것에 목을 사용하면 깨지기 쉬운 테스트(리팩터링 내성이 없는 테스트)가 된다.

이번 장에서는 목에 대해 리팩터링 내성과 회귀 방지를 최대화해서 최대 가치의 통합 테스트를 개발하는 데 도움이 되는 지침을 마저 알아본다.

먼저 일반적인 목 사용법과 그 단점을 알아보고, 단점을 극복할 수 있는 방법을 살펴본다.

9.1 목의 가치를 극대화하기

비관리 의존성에만 목을 사용하게끔 제한하는 것이 중요하지만, 이는 목의 가치를 극대화 하기 위한 첫 번째 단계일 뿐이다.

8장에서 만든 컨트롤러의 마지막 모습은 다음과 같다.

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는 다음과 같은 구조를 가진다.

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;
		}
	}
}

위 부분에 대한 통합 테스트를 작성해보면 다음과 같다.

[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
	// 준비
	var db = new Database(ConnectionString);
	User user = CreateUser("user@mycorp.com", UserType.Employee, db);
	CreateCompany("mycorp.com", 1, db);

	var messageBusMock = new Mock<IMessageBus>(); // 목 설정
	var loggerMock = new Mock<IDomainLogger>(); // 목 설정
	var sut = new UserController(db, messageBusMock.Object, loggerMock.Object);

	Sets up the mocks

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

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

	// DB에서 데이터가 기대한대로 변경되었는지 확인
	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);

	// 목 객체를 통해 기대한대로 동작되었는지 확인
	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);
}

위 테스트는 비관리 의존성인 IMessageBus와 IDomainLogger를 목으로 처리했다.

9.1.1 시스템 끝에서 상호 작용 검증하기

사실 위의 통합 테스트에서 사용했던 목은 회귀 방지와 리팩터링 내성 측면에서 이상적이지 않다. 그 이유와 이를 해결하는 방법에 대해서 알아본다.

[팁] 목을 사용할 때 항상 다음 지침을 따르자. 시스템 끝에서 비관리 의존성과의 상호 작용을 통해 검증하라.

위 통합 테스트에서 messageBusMock의 문제점은 IMessageBus 인터페이스가 시스템 끝에 있지 않다는 것이다.

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 라이브러리(제 3자 제공) 위에 있는 래퍼다.

이 구조를 나타내면 다음과 같다. hexagonal-architecture-dialog

IBus는 컨트롤러와 메시지 버스 사이의 타입 사슬에서 마지막 고리이다. 따라서 IMessageBus 대신 IBus를 목으로 처리하면 회귀 방지를 극대화 할 수 있다. IBus를 목으로 처리하면 테스트 중 통합 테스트가 거치는 클래스의 수가 증가하므로 보호가 향상된다.

추가적으로 8장에서 살펴봤듯이, 구현이 하나뿐인 인터페이스는 목으로 처리하는 용도가 아니면 필요하지 않다. 따라서 IMessageBus 인터페이스는 필요하지 않다. IMessage 버스를 삭제하고 MessageBus로 대체할 수 있다.

IBus를 모킹하는 방식으로 변경한 통합 테스트는 다음과 같다.

[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
	var busMock = new Mock<IBus>();
    var messageBus = new MessageBus(busMock.Object);
    var loggerMock = new Mock<IDomainLogger>();
    var sut = new UserController(db, messageBus, loggerMock.Object);

	/* ... */

	busMock.Verify(
		x => x.Send(
			$"Type: USER EMAIL CHANGED; Id: {user.UserId}; NewEmail: new@gmail.com"
		), Times.Once;
}

이전 버전은 다음과 같았다.

	messageBusMock.Verify(
	    x => x.SendEmailChangedMessage(
		user.UserId, "new@gmail.com"), Times.Once);

텍스트 메시지는 외부에서 식별할 수 있는 유일한 사이드 이펙트이다. 메시지를 생성하는 데 참여하는 클래스는 단지 구현 세부 사항일 뿐이다. 개선한 결과 리팩터링을 하더라도 메시지 구조를 유지하는 한, 해당 테스트는 실패하지 않는다.

[팁] 비관리 의존성에 대한 호출은 애플리케이션을 떠나기 전에 몇 단계를 거친다. 마지막 단계를 선택하라. 외부 시스템과의 하위 호환성을 보장하는 가장 좋은 방법이며, 하위 호환성은 목을 통해 달성할 수 있는 목표다.

9.1.2 목을 스파이로 대체하기

스파이는 목과 같은 목적을 수행하는 테스트 대역이다. 스파이는 수동으로 작성하는 반면에 목은 목 프레임워크의 도움을 받아 생성한다는 것이 유일한 차이점이다. 실제로 스파이는 종종 직접 작성한 목이라고도 한다.

시스템 끝에 있는 클래스의 경우 스파이가 목보다 낫다. 스파이는 검증 단계에서 코드를 재사용해 테스트 크기를 줄이고 가독성을 향상시킨다.

IBus 인터페이스에 대한 스파이를 만들어보면 다음과 같다.

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

public class BusSpy : IBus
{
    private List<string> _sentMessages = new List<string>();

    public void Send(string message)
    {
        _sentMessages.Add(message); // Stores all sent messages locally
    }

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

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

		return this;
    }
}
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
	var busSpy = new BusSpy();
    var messageBus = new MessageBus(busSpy);
    var loggerMock = new Mock<IDomainLogger>();
    var sut = new UserController(db, messageBus, loggerMock.Object);

	/* ... */

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

BusSpy가 제공하는 플루언트 인터페이스를 통해 검증이 간결해졌고 표현력도 생겼다.

플루언트 인터페이스(fluent interface) 는 메소드 체이닝에 상당 부분 기반한 객체 지향 API 설계 메소드이며, 소스 코드의 가독성을 산문과 유사하게 만드는 것이 목적이다. - 위키피디아 -

BusSpy와 MessageBus는 모두 IBus의 래퍼이기 때문에 검증은 비슷하다. 둘 사이의 결정적인 차이는 BusSpy는 테스트 코드에 속하고, MessageBus는 제품 코드에 속한다.

9.1.3 IDomainLogger는 개선할 부분이 있을까?

busSpy가 적용된 검증문은 다음과 같다.

busSpy.ShouldSendNumberOfMessages(1)
       .WithEmailChangedMessage(user.UserId, "new@gmail.com"); // IBus 상호 작용 확인

loggerMock.Verify(
    x => x.UserTypeHasChanged(
		user.UserId, UserType.Employee, UserType.Customer),
	Times.Once); // IDomainLogger 상호 작용 확인

메시지 버스의 경우 변경시 외부 시스템에 어떤 영향을 미칠지 알 수 없으므로 메시지 구조를 변경하지 않는 것이 중요하다. 그러나 로그의 경우에는 로그의 구조가 사용자(지원 부서와 시스템 관리자)에게 그다지 중요하지 않다. 중요한 것은 로그가 있다는 사실과 로그에 있는 정보다. 따라서 IDomainLogger는 목으로 처리해도 보호 수준은 충분하다.

9.2 목 처리에 대한 모범 사례

이번 절에서는 나머지 3개의 모범 사례에 대해서 설명한다.

  • 통합 테스트에서만 목을 사용하고 단위 테스트에서는 하지 않기
  • 항상 목 호출 수 확인하기
  • 보유 타입만 목으로 처리하기

9.2.1 목은 통합 테스트만을 위한 것

목은 통합 테스트만을 위한 것이며 단위 테스트에서 목을 사용하면 안 된다. 이 지침은 비즈니스 로직과 오케스트레이션의 분리에서 비롯한다. 도메인 모델(복잡도 처리)에 대한 테스트는 단위 테스트 범주에 속하며 컨트롤러(통신처리)에 대한 테스트는 통합 테스트다. 목은 비관리 의존성에만 해당하며 컨트롤러만 이러한 의존성을 처리하는 코드이기 때문에 통합 테스트에서 컨트롤러를 테스트할 때만 목을 적용해야 한다.

9.2.2 테스트당 목이 하나일 필요는 없음

단위 테스트에서 단위는 코드 단위가 아니라 동작 단위를 의미한다. 동작 단위를 구현하는 것은 단일 클래스부터 여러 클래스에 이르기까지 다양하게 걸쳐 있을 수 있고, 아주 작은 메서드에 불과할 수도 있다. 목을 사용해도 같은 원칙이 적용된다. 동작 단위를 검증하는 데 필요한 목의 수는 관계가 없다. 목의 수는 운영에 참여하는 비관리 의존성 수에만 의존한다.

9.2.3 호출 횟수 검증하기

비관리 의존성과의 통신에 관해서는 다음 두 가지 모두 확인하는 것이 중요하다.

  • 예상하는 호출이 있는가?
  • 예상치 못한 호출은 없는가?

이 요구사항은 비고나리 의존성과 하위 호환성을 지켜야 하는 데서 비롯된다. 호환성은 양방향이어야 한다. 애플리케이션은 외부 시스템이 예상하는 메시지를 생략해서는 안 되며 예상치 못한 메시지도 생성해서는 안 된다.

메시지를 전송하는지 확인하는 것만으로는 충분하지 않다. 정확히 한 번만 전송되는지 확인해야 한다. (대부분의 목 라이브러리는 목에 다른 호출이 없는지 명시적으로 확인할 수 있게 도와준다.)

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

Growing Object-Oriented Software, Guided by Tests 에 소개된 지침이다. 서드파티 라이브러리 위에 항상 어댑터(adapter)를 작성하고 기본 타입 대신 해당 어댑터를 목으로 처리한다.

이러한 지침의 이유는 다음과 같다.

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

어댑터는 코드와 외부 환경 사이의 손상 방지 계층(anti-corruption layer)으로 작동한다. 어댑터를 통해

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

이전에 만들었던 IBus 인터페이스가 이에 부합한다. 라이브러리가 IBus만큼 훌륭하고 깔끔한 인터페이스를 제공한다 하더라도, 고유의 래퍼를 그 위에 두는 것이 좋다. 추상 계층을 두면 라이브러리 변경시의 파급 효과도 제한할 수 있다.

다시 이야기하면 목은 비관리 의존성에만 사용해야하고 이 규칙도 목에 대한 조언이다.

요약

  • 시스템 끝에서 비관리 의존성과의 상호 작용을 검증하라. 컨트롤러와 비관리 의존성 사이의 타임 사슬에서 마지막 고리를 목으로 처리하라. 이로써 회귀 방지(통합 테스트로 검증된 코드가 더 많기 때문)와 리팩터링 내성(코드의 구현 세부 사항에서 목을 분리하기 때문)이 향상될 수 있다.
  • 스파이는 직접 작성한 목이다. 시스템 끝에 있는 클래스에 대해서는 스파이가 목보다 낫다. 검증 단계에서 코드를 재사용해 테스트 크기가 줄고 가독성이 개선된다.
  • 검증문을 작성할 때 제품 코드에 의존하지 말라. 테스트에서 별도의 리터럴과 상수 집합을 사용하라. 필요하면 리터럴과 상수를 복제하라. 테스트는 제품 코드와 독립적으로 검사점을 제공해야 한다. 그렇지 않으면, 이름만 바뀔 뿐 동어 반복 테스트(아무것도 검증하지 않고 무의미한 검증문만 있는 테스트)를 만들 위험이 있다.
  • 모든 비관리 의존성에 하위 호환성이 동일한 수준으로 필요한 것은 아니다. 메시지의 정확한 구조가 중요하지 않고 메시지의 존재 여부와 전달하는 정보만 검증하면 시스템의 끝에서 비관리 의존성과의 상호 작용을 검증하라는 지침을 무시할 수 있다. 대표적인 예가 로깅이다.
  • 목은 비관리 의존성만을 위한 것이고 이러한 의존성을 처리하는 코드는 컨트롤러뿐이므로 통합 테스트에서 컨트롤러를 테스트할 때만 목을 적용해야 한다. 단위 테스트에서는 목을 사용하지 말라.
  • 테스트에서 사용된 목의 수는 관계가 없다. 목의 수는 비관리 의존성의 수에 따라 달라진다.
  • 목에 예상되는 호출이 있는지와 예상치 못한 호출이 없는지를 확인하라.
  • 보유 타입만 목으로 처리하라. 비관리 의존성에 접근하는 서드파티 라이브러리 위에 어댑터를 작성하라. 기본 타입 대신 해당 어댑터를 목으로 처리하라.