박종훈 기술블로그

8장 통합 테스트를 하는 이유 (2) - 언제 목을 써야할까? + 예시

8장 통합 테스트를 하는 이유 (2) 단위테스트 (블라디미르 코리코프)

testing illustration
Freepik image by upklyak


8.2 어떤 프로세스 외부 의존성을 직접 테스트해야 하는가?

통합 테스트는 시스템이 프로세스 외부 의존성과 어떻게 통합하는지를 검증한다.

검증을 구현하는 방식은 두 가지가 있다.

  1. 실제 프로세스 외부 의존성을 사용
  2. 해당 의존성을 목으로 대체

두 가지 방식을 언제 적용해야 하는지에 대해 알아보자.

8.2.1 프로세스 외부 의존성의 두 가지 유형

모든 프로세스 외부 의존성은 두 가지 범주로 나뉜다.

  • 관리 의존성 (전체를 제어할 수 있는 프로세스 외부 의존성): 이러한 의존성은 애플리케이션을 통해서만 접근할 수 있으며, 해당 의존성과의 상호 작용은 외부 환경에서 볼 수 없다. 대표적인 예로 데이터베이스가 있다. 외부 시스템은 보통 데이터베이스에 직접 접근하지 않고 애플리케이션에서 제공하는 API를 통해 접근한다.
  • 비관리 의존성 (전체를 제어할 수 없는 프로세스 외부 의존성): 해당 의존성과의 상호 작용을 외부에서 볼 수 있다. 예를 들면 SMTP 서버와 메시지 버스 등이 있다. 둘 다 다른 애플리케이션에서 볼 수 있는 사이드 이펙트를 발생시킨다.

관리 의존성과의 통신은 구현 세부사항이다. 반대로, 비관리 의존성과의 통신은 시스템의 식별할 수 있는 동작이다. 이러한 차이로 인해 통합 테스트에서 프로세스 외부 의존성의 처리가 달라진다.

[중요] 관리 의존성은 실제 인스턴스를 사용하고, 비관리 의존성은 목을 대체하라.

비관리 의존성에 대한 통신 패턴을 유지해야 하는 이유는 하위 호환성을 지켜야 하기 때문이다. 이 작업에는 목이 제격이다. 목을 사용하면 모든 가능한 리팩터링을 고려해서 통신 패턴 영속성을 보장할 수 있다.

반대로, 관리 의존성과 통신하는 것은 애플리케이션뿐이므로 하위 호환성을 유지할 필요가 없다. 외부 클라이언트는 데이터베이스를 어떻게 구성하는지 신경을 쓰지 않는다. 중요한 것은 시스템의 최종 상태이다. 통합테스트에서 관리 의존성의 실제 인스턴스를 사용하면 외부 클라이언트 관점에서 최종 상태를 확인할 수 있다. 또한 컬럼을 변경하거나 데이터베이스를 이관하는 등 데이터베이스 리팩터링에도 도움이 된다.

시스템 통합시에 데이터베이스를 함께 사용하면 시스템이 서로 결합되고 추가 개발을 복잡하게 만들기 때문에 좋지 않다. API나 메시지 버스를 사용하는 것이 더 낫다. 공유 데이터베이스를 사용하는 시스템을 테스트해야할 경우에는 Mock을 사용하라. 데이터베이스와의 상호 작용이 아닌 데이터베이스의 최종 상태를 확인하라. 공유 데이터베이스는 외부에서 볼 수 있다.

8.2.3 통합 테스트에서 실제 데이터베이스를 사용할 수 없으면 어떻게 할까?

관리 의존성임에도 불구하고 데이터베이스를 목으로 처리해야 할까? 그렇지 않다. 관리 의존성을 목으로 대체하면 통합 테스트의 리팩터링 내성 및 회귀 방지가 저하되기 때문이다. 데이터베이스를 그대로 테스트할 수 없으면 통합 테스트를 아예 작성하지 말고 모데인 모델의 단위 테스트에만 집중하라. 가치가 충분하지 않은 테스트는 테스트 스위트에 있어서는 안 된다.

8.3 통합 테스트: 예제

아래 예제는 컨트롤러의 현재 모습이다.

public class UserController
{
    private readonly Database _database = new Database();
    private readonly MessageBus _messageBus = new MessageBus();

    public string ChangeEmail(int userId, stringEmail)
    {
        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);

        foreach (var ev in user.EmailChangedEvents) {
            _messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
        }
        return "OK";
    }
}

8.3.1 어떤 시나리오를 테스트할까?

통합 테스트에 대한 일반적인 지침은 가장 긴 주요 흐름과 단위 테스트로는 수행할 수 없는 모든 예외 상황을 다루는 것이다. 가장 긴 주요 흐름은 모든 프로세스 외부 의존성을 거치는 것이다.

이 코드에서 가장 긴 주요 흐름은 기업 이메일에서 일반 이메일로 변경하는 것이다. 이 변경이 사이드 이펙트가 가장 많다.

  • 데이터베이스에서 사용자와 회사 모두 업데이트된다. 즉 사용자는 유형을 변경하고 이메일도 변경하며, 회사는 직원 수를 변경한다.
  • 메시지 버스로 메시지를 보낸다.

단위테스트로는 이메일을 변경할 수 없는 시나리오를 테스트하지 못한다. 그러나 이 시나리오를 테스트할 필요는 없다. 컨트롤러에 이러한 확인이 없으면 애플리케이션이 빨리 실패하기 때문이다.

따라서 아래 테스트를 하는 것이 좋다.

public void Changing_email_from_corporate_to_non_corporate()

8.3.2 데이터베이스와 메시지 버스 분류하기

여기서 데이터베이스는 어떤 시스템도 접근할 수 없으므로 관리 의존성이다. 따라서 실제 인스턴스를 사용한다. 이 통합 테스트는

  • 데이터베이스에 사용자와 회사를 삽입하고,
  • 해당 데이터베이스에서 이메일 변경 시나리오를 실행하며,
  • 데이터베이스 상태를 검증하게 된다.

반면에 메시지 버스는 비관리 의존성이다. 메시지 버스의 목적은 다른 시스템과의 통신을 가능하게 하는 것뿐이다. 따라서 목을 사용하여 컨트롤러와 목 간의 상호 작용을 검증한다.

8.3.3 엔드 투 엔드 테스트를 사용하는건 어떨까?

엔드 투 엔드 테스트는 어떤 프로세스 외부 의존성도 목으로 대체하지 않는 것을 의미한다. 선택은 각자의 판단에 따른다.

8.3.4 통합 테스트 첫 번째 시도

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

    var messageBusMock = new Mock<IMessageBus>()
    var sut = new UserController(db, messageBusMock.Object);

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

    // 검증
    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);
    // 회사 상태 검증 끝

    // 목 상호 작용 확인 시작
    messageBusMock.Verify(
        x => x.SendEmailChangedMessage(
            user.UserId, "new@gmail.com"),
            Times.Once);
    // 목 상호 작용 확인 끝
}

[팁] 테스트는 준비 구절에서 사용자와 회사를 데이터베이스에 삽입하지 않고, CreateUser와 CreateCompany 헬퍼 메서드를 호출한다. 이러한 메서드는 여러 통합 테스트에서 재사용할 수 있다.

데이터베이스 상태를 확인하는 것은 중요하다. 이를 위해 통합 테스트 검증 구절에서 사용자와 회사 데이터를 각각 조회하고, 이후 새로운 userFromDb와 companyFromDb 인스턴스를 생성한 후에 해당 상태를 검증만 한다.

이 방법을 사용하면 테스트가 데이터베이스에 대해 읽기와 쓰기를 모두 수행하므로 회귀 방지를 최대로 얻을 수 있다.

읽기는 컨트롤러에서 내부적으로 사용하는 동일한 코드를 써서 구현한다.

9장과 10장에서는 목과 데이터베이스 테스트 모범 사례를 통해 개선 사항을 설명한다.