HCN DEV

Read in English

Test Spy

Test Spy feature image

설명에 사용하는 PlayGround를 확인 가능합니다.

1. What is Spy?

SpySUT의 verify 단계에서 indirect output을 제공하기 위해 사용되는 Test Double입니다. 객체를 정의하는 개발자는 객체가 오남용되는 것을 방지하기 위해 사용자가 필요한 API만 public API로 제공합니다. 이는 객체의 역할 분배를 적절히 나누기 위해 좋은 방식이지만, 이 객체를 테스트하고 싶을 때에는 불편한 점이 생깁니다. 예를 들어서, public API가 리턴 값을 제공하지 않는다면 해당 API만으로는 동작이 올바른지 판단할 수 없습니다. 또한, API에서 리턴 값을 제공하더라도 이 리턴값이 적절한 과정을 통해서 만들어진 것인지는 확인이 어렵습니다.

이처럼 public API를 통해서 확인하기 어려운 SUT의 actions들을 indirect output이라고 부릅니다. Spy는 (흔히 우리가 생각하는 스파이처럼) 본래의 SUT는 제공하지 않는 indirect output을 제공하는 Test Double입니다. indirect output은 객체 내부의 private으로 정의된 property 형태 일수도 있고, 어떤 API의 호출 여부에 대한 정보, 혹은 호출 순서에 대한 정보가 되기도 합니다.

2. Spy를 통한 UnitTest 수행 과정

Spy는 UnitTest의 setup 단계에서 SUTDOC를 대체하는 형태로 사용됩니다. Spy는 기존의 real-DOC의 인터페이스는 그대로 따르도록 구성하고, 여기에 추가적으로 real-DOC에서는 제공하지 않는 함수 호출 기록, private properties(indirect output)를 접근할 수 있도록 합니다.(observation point 제공) 이후에 verification 단계에서 테스트 작성자는 Spy에서 제공하는 indirect output을 추가적으로 검증하여 Untested requirement를 최소화합니다.

공항과 비행기와 관련된 예시를 통해서 Spy의 사용성에 대해서 살펴보겠습니다. 공항에 비행기가 들어오고 나가는 기능을 테스트하고 싶을 때, 아래와 같은 형태로 테스트를 수행할 수 있습니다.

func testRealDOCFlight() {
    let flights: [Flight] = [Flight(number: 1),
                             Flight(number: 2),
                             Flight(number: 3),
                             Flight(number: 4)]

    var airPort = Airport(flights: flights, controlTower: ControlTower())

    // exercise
    airPort.removeFlight(number: 3)
    airPort.addFlights([.init(number: 5)])

    // verify
    assert(airPort.flights.count == 4)
    assert(airPort.hasFlight(number: 3) == false)
    assert(airPort.hasFlight(number: 5) == true)

    let removeNotification = airPort.controlTower.notifications.first
    assert(removeNotification?.actionCode == "remove(number:)")
}
상세 코드 보기
import Foundation

public struct Flight {
    public let number: Int

    public init(number: Int) {
        self.number = number
    }
}

public protocol FlightManagable {
    var flights: [Flight] { get set }
    var controlTower: FlightReportable { get set }
    mutating func removeFlight(number: Int)
    func hasFlight(number: Int) -> Bool
}

public protocol FlightReportable {
    var notifications: [ControlTower.Notification] { get }
    mutating func report(date: Date, actionCode: String, detail: Any?)
}

public struct ControlTower: FlightReportable {
    public struct Notification {
        public var date: Date
        public var actionCode: String
        public var detail: Any?
    }

    public var notifications: [ControlTower.Notification] = []

    public init() {
        self.notifications = []
    }

    public mutating func report(date: Date, actionCode: String, detail: Any?) {
        let notification = ControlTower.Notification(date: date, actionCode: actionCode, detail: detail)
        self.notifications.append(notification)
    }
}

public struct Airport: FlightManagable {
    public var flights: [Flight] = []
    public var controlTower: FlightReportable

    public init(flights: [Flight], controlTower: FlightReportable) {
        self.flights = flights
        self.controlTower = controlTower
    }

    public mutating func addFlights(_ flights: [Flight]) {
        self.flights.append(contentsOf: flights)
        controlTower.report(date: Date(), actionCode: "add(flights:)", detail: nil)
    }

    public mutating func removeFlight(number: Int) {
        let filteredFlight = flights.filter { $0.number != number }
        self.flights = filteredFlight
        controlTower.report(date: Date(), actionCode: "remove(number:)", detail: number)
    }

    public func hasFlight(number: Int) -> Bool {
        return flights.contains(where: { $0.number == number })
    }
}

위와 같이 테스트를 수행하게 되면, 최종적으로 AirPort에 몇 개의 비행기가 있고 ControlTower에 쌓인 Notification 정보는 확인이 가능합니다. 하지만, 이 상황에서 AirPort의 상태가 어떤 메소드의 호출들을 통해 구성되었는지를 알 수는 없습니다. 이런 경우 AirPort가 의존하는 ControlTowerSpy로 교체하여 해당 정보를 확인할 수 있습니다.

public struct ControlTowerSpy: FlightReportable {
    public var notifications: [ControlTower.Notification]
    public var numberOfReports: Int = 0

    public init() {
        self.notifications = []
        self.numberOfReports = 0
    }

    public mutating func report(date: Date, actionCode: String, detail: Any?) {
        let notification = ControlTower.Notification(date: date,
                                                     actionCode: actionCode,
                                                     detail: detail)
        self.notifications.append(notification)
        self.numberOfReports += 1
    }
}

위처럼 Spy 객체를 정의하게 되면 실제 ControlTower(real-DOC)에서는 제공하지 않는 numberOfReports 정보(indirect output)를 추가로 기록할 수 있게 되고, 이를 통해 AirPort의 report가 실제로 2번 호출되었음을 확인할 수 있습니다.

func testSpyFlight() {
    let flights: [Flight] = [Flight(number: 1),
                             Flight(number: 2),
                             Flight(number: 3),
                             Flight(number: 4)]

    let controlTowerSpy = ControlTowerSpy()
    var airPort = Airport(flights: flights, controlTower: controlTowerSpy)

    // exercise
    airPort.removeFlight(number: 3)
    airPort.addFlights([.init(number: 5)])

    // verify
    assert(airPort.flights.count == 4)
    assert(airPort.hasFlight(number: 3) == false)
    assert(airPort.hasFlight(number: 5) == true)

    let removeNotification = airPort.controlTower.notifications.first
    assert(removeNotification?.actionCode == "remove(number:)")

    // indirect output
    let spy = airPort.controlTower as? ControlTowerSpy
    assert(spy?.numberOfReports == 2)
}

3. Spy를 사용하는 시점

SUT의 Method를 수행하는 과정에서 side effect가 발생하였고 이로 인해 Untested requirement이 생겨날 때 Spy를 사용합니다. Spy는 SUT의 동작 과정에서 기록되는 값들의 observation points로 활용되고, 아래와 같은 경우에 사용하는 것을 고려할 수 있습니다.

  • SUT의 indirect output을 verify하는 중간에 SUT의 모든 attributes의 변경을 예측하기 어려울 때
  • Assertion이 테스트 도중에 더 잘 보이도록 구성하고 싶고, 이를 Mock의 expectation만으로는 테스트의 의도를 충분히 드러낼 수 없을 때

3-2. Spy와 Clean Swift

Clean Swift에서 UseCase를 테스트하는 과정에서 Spy를 사용하면, 테스트를 좀 더 쉽게 작성할 수 있습니다. Clean Swift은 interactor, presenter, viewController가 단방향으로 자신이 의존하는 객체의 메소드를 호출합니다. 그에 따라, 메소드들은 모두 리턴 값이 없고, 의존하는 객체가 존재합니다. 테스트 작성자는 SUT의 메소드 호출 이후 상태를 체크하는 방법 이외에 자신이 의존하는 DOC를 Spy로 변경하고 테스트를 수행하면 테스트 과정에서 좀 더 다양한 정보를 얻을 수 있습니다.

func testSpyJsonViewerShowJson() {
    // setup
    let interactor = JsonViewer.Interactor()
    let presenterSpy = JsonViewer.PresenterSpy()
    interactor.presenter = presenterSpy

    // exercise
    let sampleData = """
    {
        "spy": true
    }
    """.data(using: .utf8)!

    interactor.showJson(request: .init(data: sampleData))

    // Verify indirect output
    assert(presenterSpy.presentJsonIsCalled)
    assert(presenterSpy.jsonModel?.spy ?? false == true)
}

참고자료