Test Spy
05 Jan 2022You 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
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)
}