Test Spy
05 Jan 2022설명에 사용하는 PlayGround를 확인 가능합니다.
1. What is Spy?
Spy
는 SUT
의 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 단계에서 SUT
의 DOC
를 대체하는 형태로 사용됩니다. 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:)")
}
상세 코드 보기
위와 같이 테스트를 수행하게 되면, 최종적으로 AirPort
에 몇 개의 비행기가 있고 ControlTower
에 쌓인 Notification 정보는 확인이 가능합니다. 하지만, 이 상황에서 AirPort
의 상태가 어떤 메소드의 호출들을 통해 구성되었는지를 알 수는 없습니다. 이런 경우 AirPort
가 의존하는 ControlTower
를 Spy
로 교체하여 해당 정보를 확인할 수 있습니다.
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)
}