[testing] Mocking best practices

Donghoon Bae·2025년 9월 15일

testing

목록 보기
5/6

Mock의 가치를 극대화하기

public final class UserController {
  private let database: Database
  private let eventDispatcher: EventDispatcher

  public init(
    database: Database, 
    messageBus: MessageBusProtocol, 
    domainLogger: DomainLoggerProtocol
  ) {
    self.database = database
    self.eventDispatcher = EventDispatcher(
      messageBus: messageBus, 
      domainLogger: domainLogger
    )
  }

  public func changeEmail(
    userId: Int, 
    newEmail: String
  ) -> String? {
    let userData = database.getUserById(userId)
    let user = UserFactory.create(userData)

    if let error = user.canChangeEmail() {
      return error
    }

    let companyData = database.getCompany()
    let company = CompanyFactory.create(companyData)
    user.changeEmail(newEmail, company: company)

    database.saveCompany(company)
    database.saveUser(user)
    eventDispatcher.dispatch(user.domainEvents)

    return "OK"
  }
}

위의 예제에는 EventDispatcher라는 새로운 클래스가 도입되었다.
EventDispatcher 클래스는 도메인 모델에서 생성된 도메인 이벤트를 비관리 의존성에 대한 호출로 변환해준다.

public final class EventDispatcher {
  private let messageBus: MessageBusProtocol
  private let domainLogger: DomainLoggerProtocol

  public init(
    messageBus: MessageBusProtocol, 
    domainLogger: DomainLoggerProtocol
  ) {
    self.messageBus = messageBus
    self.domainLogger = domainLogger
  }

  public func dispatch(_ events: [DomainEventProtocol]) {
    for ev in events {
      dispatch(ev)
    }
  }

  private func dispatch(_ ev: DomainEventProtocol) {
    switch ev {
    case let emailChanged as EmailChangedEvent:
      messageBus.sendEmailChangedMessage(userId: emailChanged.userId, newEmail: emailChanged.newEmail)
    case let userTypeChanged as UserTypeChangedEvent:
      domainLogger.userTypeHasChanged(userId: userTypeChanged.userId, oldType: userTypeChanged.oldType, newType: userTypeChanged.newType)
    default:
      break
    }
  }
}

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

import Testing

public final class UserControllerTests {
  @Test
  public func changingEmailFromCorporateToNonCorporate() async throws {
  	// Arrange
    let db = Database(connectionString: connectionString)
    let user = createUser(
      "user@mycorp.com", 
      type: .employee, 
      db: db
    )
    createCompany(
      "mycorp.com", 
      numberOfEmployees: 1, 
      db: db
    )

    let messageBusMock = MessageBusMock()
    let loggerMock = DomainLoggerMock()
    let sut = UserController(
      database: db, 
      messageBus: messageBusMock, 
      domainLogger: loggerMock
    ) // Sets up the mocks

	// Act
    let result = sut.changeEmail(
      userId: user.userId, 
      newEmail: "new@gmail.com"
    )
    
    // Assert
    #expect(result == "OK")

    let userData = db.getUserById(user.userId)
    let userFromDb = UserFactory.create(userData)
    #expect(userFromDb.email == "new@gmail.com")
    #expect(userFromDb.type == .customer)

    let companyData = db.getCompany()
    let companyFromDb = CompanyFactory.create(companyData)
    #expect(companyFromDb.numberOfEmployees == 0)

    #expect(messageBusMock.sendEmailChangedMessageCalls.count == 1)
    #expect(messageBusMock.sendEmailChangedMessageCalls.first?.userId == user.userId)
    #expect(messageBusMock.sendEmailChangedMessageCalls.first?.newEmail == "new@gmail.com")

    #expect(loggerMock.userTypeHasChangedCalls.count == 1)
    #expect(loggerMock.userTypeHasChangedCalls.first?.userId == user.userId)
    #expect(loggerMock.userTypeHasChangedCalls.first?.oldType == .employee)
    #expect(loggerMock.userTypeHasChangedCalls.first?.newType == .customer) // Verifies the interactions with the mocks
  }
}

시스템의 경계에서의 상호 작용 검증

목을 사용할 때는 항상 다음 지침을 준수해야 한다. 비관리형 의존성과의 상호작용을 시스템의 가장자리(very edges)에서 검증한다.
messageBusMock의 문제는 IMessageBus 인터페이스가 시스템의 가장자리에 위치하지 않는다는 것이다

public protocol MessageBusProtocol {
  func sendEmailChangedMessage(userId: Int, newEmail: String)
}

public final class MessageBus: MessageBusProtocol {
  private let bus: BusProtocol

  public init(bus: BusProtocol) {
    self.bus = bus
  }

  public func sendEmailChangedMessage(userId: Int, newEmail: String) {
    bus.send("Type: USER EMAIL CHANGED; Id: \(userId); NewEmail: \(newEmail)")
  }
}

public protocol BusProtocol {
  func send(_ message: String)
}

MessageBus 클래스와 IBus 인터페이스는 둘 다 코드베이스에 속해있다.
IBus는 메시지 버스 SDK의 wrapper로 임의의 텍스트 메시지를 별도의 자격 증명없이 전송할 수 있는 깔끔한 인터페이스라고 가정한다.
IMessageBus는 도메인과 관련된 메시지를 정의하여, 모든 메시지를 한 곳에 모으고 애플리케이션에서 재사용할 수 있도록 해준다.
IBus와 IMessageBus 인터페이스를 통합할 수는 있지만 두 가지 책임을 가지게 되므로 이는 어디까지나 차선책에 불과하다.
육각형 아키텍쳐 입장에서 IBus와 IMessageBus를 나타내보면 아래와 같다.

IMessageBus 대신 IBus를 Mocking하는 경우를 생각해보자.
IBus는 시스템의 끝에 위치하기에 Mock으로 처리시 회귀 방지를 극대화할 수 있을 것이다.
즉 비관리 의존성과 통신하는 마지막 타입을 Mock으로 처리하면 통합 테스트가 거치는 클래스의 수가 증가하므로 회귀 방지가 극대화되는 것이다.

IMessageBus를 IBus로 바꾼 다면 통합 테스트는 아래와 같이 변경될 것이다.

import Testing

@Test
public func changingEmailFromCorporateToNonCorporate() async throws {
  let busMock = BusMock()
  let messageBus = MessageBus(bus: busMock) // Uses a concrete class instead of the interface
  let loggerMock = DomainLoggerMock()
  let sut = UserController(database: db, messageBus: messageBus, domainLogger: loggerMock)

  /* ... */

  #expect(busMock.sendCalls.count == 1)
  #expect(busMock.sendCalls.first == "Type: USER EMAIL CHANGED; Id: \(user.userId); NewEmail: new@gmail.com") // Verifies the actual message sent to the bus
}

Mock을 Spy로 대체하기

Spy는 Mock처럼 동일한 목적을 수행하는 테스트 대역이다. 단지 프레임워크의 도움을 받아 생성하는 Mock과 달리 직접 작성해야한다는 차이점이 있을 뿐이다. 시스템의 경계에 있는 클래스의 경우는 Mock보다는 Spy가 더 나은 대안이 되기도 한다. Spy는 검증 단계에서 코드를 재사용함으로써, 테스트의 크기를 줄이고 가독성을 향상 시킨다.

  • IBus 위에서 작동하는 Spy의 예제
public protocol BusProtocol {
  func send(_ message: String)
}

public final class BusSpy: BusProtocol {
  private var sentMessages: [String] = []

  public init() {}

  public func send(_ message: String) {
    sentMessages.append(message)
  }

  @discardableResult
  public func shouldSendNumberOfMessages(_ number: Int) -> Self {
    #expect(sentMessages.count == number)
    return self
  }

  @discardableResult
  public func withEmailChangedMessage(userId: Int, newEmail: String) -> Self {
    let message = "Type: USER EMAIL CHANGED; Id: \(userId); NewEmail: \(newEmail)"
    #expect(sentMessages.contains(message))
    return self
  }
}
  • BusSpy를 적용한 새로운 통합 테스트 코드
import Testing

@Test
public func changingEmailFromCorporateToNonCorporate() async throws {
  let busSpy = BusSpy()
  let messageBus = MessageBus(bus: busSpy)
  let loggerMock = DomainLoggerMock()
  let sut = UserController(database: db, messageBus: messageBus, domainLogger: loggerMock)

  /* ... */

  busSpy
    .shouldSendNumberOfMessages(1)
    .withEmailChangedMessage(userId: user.userId, newEmail: "new@gmail.com")
}

IDomainLogger는 어떻게 하는가?

  busSpy
    .shouldSendNumberOfMessages(1)
    .withEmailChangedMessage(userId: user.userId, newEmail: "new@gmail.com") // Check interactions with IBus
    
  loggerMock
    .userTypeHasChangedCalls
    .shouldHaveCount(1)
    .shouldContain { call in
      call.userId == user.userId &&
      call.oldType == .employee &&
      call.newType == .customer
    } // Checks interactions with IDomainLogger

테스트에서 목(Mock)을 사용할 때, IDomainLogger는 IMessageBus와 다르게 취급한다. IDomainLogger는 ILogger의 래퍼이고, ILogger는 애플리케이션 경계에 있다. 하지만 대부분의 프로젝트에서는 IDomainLogger를 ILogger로 다시 타겟팅할 필요는 없다. 로거와 메시지 버스 모두 비관리형 의존성이다. 둘 다 역방향 호환성을 유지해야 하지만, 그 정확도는 같을 필요가 없다. 메시지 버스는 메시지 구조가 바뀌면 외부 시스템에 영향을 줄 수 있으므로, 정확한 구조를 변경하면 안 된다.
하지만 텍스트 로그의 정확한 구조는 지원 담당자나 시스템 관리자에게 그렇게 중요하지 않다. 중요한 것은 로그의 존재 여부와 어떤 정보를 전달하는지이다. 따라서 IDomainLogger만 목(Mock)해도 필요한 수준의 보호를 제공한다.

Mocking의 모범 사례

  1. 비관리 의존성에만 Mock 적용하기(Applying mocks to unmanaged dependencies only)
  2. 시스템 끝에 있는 의존성에 대해 상호 작용 검증 (Verifying the interactions with those dependencies at the very edges of your system)
  3. 통합 테스트에서만 Mock을 사용하고 단위 테스트에서는 사용하지 않기(Using mocks in integration tests only, not in unit tests)
  4. 항상 Mock의 호출 수를 확인하기(Always verifying the number of calls made to the mock)
  5. 보유한 타입에 대해서만 Mock으로 처리하기(Mocking only types that you own)

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

목은 통합 테스트에만 사용해야 하며 단위 테스트에서는 목을 사용해서는 안 된다는 지침은 근본적인 원칙인 비즈니스 로직과 오케스트레이션의 분리에서 비롯된다. 코드는 프로세스 외부 의존성(out-of-process dependencies)과 통신하거나 복잡해야 하지만, 둘 다 동시에 해서는 안 된다. 이 원칙은 자연스럽게 도메인 모델(복잡성을 다루는)과 컨트롤러(통신을 다루는)라는 두 개의 독립된 계층을 형성하게 한다.

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

때때로 사람들은 통합 테스트에서 단 하나의 목만 사용해야 한다고 오해한다. 즉, 단위 테스트에서의 '단위(unit)'는 코드의 단위를 의미하며, 이러한 모든 단위는 서로 격리되어 테스트되어야 한다는 생각이다. 반대로, '단위'라는 용어는 코드의 단위가 아니라 행동의 단위(unit of behavior)를 의미한다. 이러한 행동 단위를 구현하는 데 필요한 코드의 양은 중요하지 않다. 이는 여러 클래스에 걸쳐 있을 수도 있고, 단일 클래스일 수도 있으며, 아주 작은 메서드일 수도 있다.
목에도 같은 원칙이 적용된다. 행동 단위를 검증하는 데 필요한 목의 개수는 중요하지 않다. 이 장 초반에 사용자 이메일을 기업용에서 비기업용으로 변경하는 시나리오를 확인하는 데 로거용 목과 메시지 버스용 목, 총 두 개의 목이 필요했다. 이 개수는 더 많을 수도 있었다. 사실, 통합 테스트에서 사용할 목의 개수는 제어할 수 없다. 목의 개수는 전적으로 해당 작업에 참여하는 비관리형 의존성의 수에 따라 달라진다.

호출 횟수 검증하기

비관리형 의존성과의 통신에 있어서 다음 두 가지를 모두 보장하는 것이 중요하다

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

이러한 요구사항은 다시 한번 비관리형 의존성과의 역방향 호환성을 유지해야 할 필요성에서 비롯된다. 호환성은 양방향으로 적용되어야 한다. 즉, 애플리케이션은 외부 시스템이 기대하는 메시지를 누락해서는 안 되며, 예상치 못한 메시지를 생성해서도 안 된다. 시스템이 이와 같은 메시지를 보낸다는 것만 확인하는 것으로는 충분하지 않다.

messageBusMock
  .sendEmailChangedMessageCalls
  .shouldContain { call in
    call.userId == user.userId && call.newEmail == "new@gmail.com"
  }

이 메시지가 정확히 한 번만 전송되었는지도 확인해야 한다:

#expect(messageBusMock.sendEmailChangedMessageCalls.count == 1)
#expect(messageBusMock.sendEmailChangedMessageCalls.first?.userId == user.userId)
#expect(messageBusMock.sendEmailChangedMessageCalls.first?.newEmail == "new@gmail.com")

대부분의 목킹 라이브러리를 사용하면 목에 대한 다른 호출이 없는지 명시적으로 확인할 수도 있다. 선택한 목킹 라이브러리인 Moq에서는 이러한 검증이 다음과 같이 나타난다:

#expect(messageBusMock.sendEmailChangedMessageCalls.count == 1)
#expect(messageBusMock.sendEmailChangedMessageCalls.first?.userId == user.userId)
#expect(messageBusMock.sendEmailChangedMessageCalls.first?.newEmail == "new@gmail.com")
#expect(messageBusMock.sendEmailChangedMessageCalls.count == messageBusMock.totalCalls)

BusSpy도 이 기능을 구현한다:

busSpy
  .shouldSendNumberOfMessages(1)
  .withEmailChangedMessage(userId: user.userId, newEmail: "new@gmail.com")

스파이의 shouldSendNumberOfMessages(1) 검증은 목의 Times.Once와 VerifyNoOtherCalls() 검증을 모두 포함한다.

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

마지막 지침은 보유한 타입에 한정해서 Mock으로 처리하라는 것이다. Steve Freeman, Nat Pryce가 주장하는 바에 따르면, 서드파티 라이브러리 위에 항상 어댑터를 작성하고 기본 타입대신 해당 어댑터를 Mock으로 처리해야한다고 말한다.

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

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

어댑터를 이용하면

  • 기본 라이브러리의 복잡성을 추상화하고
  • 라이브러리에서 필요한 기능만 노출하며
  • 프로젝트 도메인 언어를 사용해 수행할 수 있다.
    즉 프로젝트가 “보유한 어댑터” 를 Mock의 대상으로 삼아 테스트를 수행하는 것이 좋다.
profile
iOS & Swift

0개의 댓글