HCN DEV

한국어로 보기

Test Spy

Test Spy feature image

You can check the PlayGround used in the explanation.

1. What is Spy?

Spy is a type of Test Double used in the verification phase of the SUT to provide “indirect output.” Developers exposes necessary methods of the object as public to prevent misuse of the object. While this is a good way to appropriately distribute the roles of objects, it can be inconvenient when you want to test the object. For example, if public methods do not provide return values, you cannot determine if the method alone is functioning correctly. Furthermore, even if an method provides a return value, it is difficult to confirm whether the return value is created through the appropriate process.

These actions that are difficult to confirm through public APIs of the SUT are referred to as “indirect output.” Spy is a Test Double that provides this “indirect output,” which the original SUT does not provide (commonly as a spy). “Indirect output” can take the form of properties defined internally in the object, information about whether a specific API was called, or information about the order of calls.

2. The Process of Performing Unit Testing with Spy

Spy is used in the setup phase of Unit Testing as a replacement for the SUT’s DOC (Depended On Component). The Spy is configured to follow the interface of the original real-DOC, and it additionally allows access to function call records and private properties (indirect output) not provided by the real-DOC (provides an observation point). In the verification phase, the test author further validates the “indirect output” provided by the Spy to minimize “Untested Requirement.”

Let’s examine the usability of Spy using an example related to airports and airplanes. When you want to test the functionality of airplanes entering and leaving an airport, you can perform the test as follows:

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:)")
}
View Detailed Code
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 })
    }
}

When you perform the test as shown above, you can confirm the number of airplanes in AirPort and the notifications accumulated in ControlTower. However, in this situation, you cannot know how AirPort’s state was constructed through which method calls. In such cases, you can replace ControlTower, which AirPort depends on, with a Spy to verify this information.

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

By defining a Spy object as shown above, you can record additional information (indirect output) not provided by the original ControlTower (real-DOC). This way, you can confirm that the report function in AirPort was actually called twice.

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. When to Use Spy

Spy is used when “side effects” occur during the execution of the SUT’s methods, leading to the creation of “Untested Requirement.” Spy is used as “observation points” for the values recorded during the SUT’s operation, and it is considered for use in the following cases:

  • When it is difficult to predict all attribute changes of the SUT during the verification of “indirect output”
  • When you want assertions to be more visible during testing, and you cannot adequately express the test’s intent with only the expectations of a Mock.

4. Spy and Clean Swift

When testing UseCases in Clean Swift, using Spy can make testing easier. Clean Swift involves the interactor, presenter, and viewController calling methods of the objects they depend on in a one-way direction. Consequently, all methods do not return values, and dependencies exist. Test authors can change the DOC they depend on to Spy instead of checking the state after calling the SUT’s method. This allows you to obtain more diverse information during the testing process.

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

References