Cleaner asynchronous tests with XCTest
Overview
This week I participated at a code dojo at work organised by @mikeweller where the theme was test driven development, more specifically about using test doubles to test our components.
The exercise revolved around designing an oxygen regulator for a space ship that needs to ensure oxygen levels are within a specified range. The catch was, we did not yet have access to any of the other components of the system as they were still in development (such as the oxygen meter and alarm).
For the purposes of this post, I will focus on a subset of the requirements and use it to highlight some testing tips I’ve come across especially around writing cleaner asynchronous tests.
Requirements
- The regulator will have a
tick()
method that is regularly called - When the oxygen levels are below 20.3% an alarm needs to be triggered
Types
With the information we have so far, the only type requirement was for the regulator to have a tick()
method, we also know our minimum oxygen level.
protocol Regulator {
func tick()
}
struct Config {
var minLevel: Double
}
extension Config {
static var defaultConfig: Config {
return Config(minLevel: 0.203)
}
}
For our other dependencies however, there wasn’t anything concrete, as such we had to come up with our own
protocol Alarm {
func notify()
}
protocol Meter {
var level: Double { get }
}
The Test
At a high level our test needs to look like this:
func testAlarmIsTriggeredWhenLevelsAreLow() {
// Verify the alarm is not triggered
// Set Meter level lower than the minimum level
// Tick the regulator
// Verify the alarm was triggered
}
A first pass of a test could look like this
var lowLevel: Double {
return Config.defaultConfig.minLevel - 0.01
}
func testAlarmIsTriggeredWhenLevelsAreLow() {
let alarm = MockAlarm()
let meter = MockMeter()
let regulator = OxygenRegulator(meter: meter, alarm: alarm)
XCTAssertFalse(alarm.notified)
meter.level = lowLevel
regulator.tick()
XCTAssertTrue(alarm.notified)
}
Where our mocks are:
class MockMeter: Meter {
var level: Double = Config.defaultConfig.minLevel
}
class MockAlarm: Alarm {
var notified = false
func notify() {
notified = true
}
}
Asynchronous Cases
The mock alarm we used utilises a boolean to keep track of its state, which works well in the synchronous cases but if our component is asynchronous this simple mock won’t quite cut it. A closure would be more suitable.
class MockAlarm: Alarm {
var didNotify: (()->Void)?
func notify() {
didNotify?()
}
}
This would allow the usage of XCTestExpectation
to wait until the alarm is triggered.
func testAsyncAlarmIsTriggeredWhenLevelsAreLow() {
let alarm = MockAlarm()
let meter = MockMeter()
let regulator = OxygenRegulator(meter: meter, alarm: alarm)
let e = expectation(description: "Alarm should be triggered")
alarm.didNotify = {
e.fulfill()
}
meter.level = lowLevel
regulator.tick()
waitForExpectations(timeout: 1, handler: nil)
}
Cleaner Asynchronous Cases
So far everything covered is pretty straight forward, however as more and more requirements around when to trigger the alarm come in we’ll find quite a bit of repetition around setting up the expectations and waiting. While nothing is wrong with that, there are a few steps that can be taken to clean it up.
Helper Methods
We can create a helper method that does all the expectations setup:
func verifyAlarmTriggers(alarm: MockAlarm,
file: StaticString = #file,
line: UInt = #line,
when action: ()->Void) {
// Setup expectations
let e = expectation(description: “Alarm is triggered")
alarm.didNotify = {
e.fulfill()
}
// Perform action
action()
// Wait for expectations
waitForExpectations(timeout: 1) { error in
if let _ = error {
XCTFail(“Alarm was not triggered", file: file, line: line)
}
}
}
Which would allow us to refactor our test to look like this:
func testAsyncAlarmIsTriggeredWhenLevelsAreLow() {
let alarm = MockAlarm()
let meter = MockMeter()
let regulator = OxygenRegulator(meter: meter, alarm: alarm)
verifyAlarmTriggers(alarm: alarm) {
meter.level = lowLevel
regulator.tick()
}
}
If you haven’t run into #file
& #line
before, they are your new best friends when creating helper test methods! They will capture the file and line number of the caller which can then be passed to the XCTest
suite of methods and end up showing the error at that particular line instead of the helper!
Note the when
closure parameter is placed at the end of the parameters list of our helper method. At first glance it may look odd as its preceded by the file
and line
parameters with default values, but doing so allows us to leverage the trailing closure syntax.
Using setup()
This has already reduced quite a bit of repetition, but we can reduce it even further by leveraging setup()
to setup our components:
var alarm: MockAlarm!
var meter: MockMeter!
var regulator: OxygenRegulator!
override func setUp() {
super.setUp()
alarm = MockAlarm()
meter = MockMeter()
regulator = OxygenRegulator(meter: meter, alarm: alarm)
}
Seeing our mocks are now a property of the test case, our helper method would be able to access the alarm mock without needing it to be passed in:
func verifyAlarmTriggers(file: StaticString = #file,
line: UInt = #line,
when action: ()->Void) {
// Setup expectations
let e = expectation(description: “Alarm is triggered")
alarm.didNotify = {
e.fulfill()
}
// Perform action
action()
// Wait for expectations
waitForExpectations(timeout: 1) { error in
if let _ = error {
XCTFail(“Alarm was not triggered", file: file, line: line)
}
}
}
Finally our test can now be reduced down to this:
func testAsyncAlarmIsTriggeredWhenLevelsAreLow() {
verifyAlarmTriggers {
meter.level = lowLevel
regulator.tick()
}
}
Conclusion
There are actually a few frameworks out there that introduce a wide variety of utilities to help you write cleaner tests, for example Nimble has a few asynchronous helpers like toEventually
and toEventuallyNot
. However if you are not quite ready to adopt a third party framework for whatever reason, there’s nothing stopping you from writing cleaner tests with XCTest
.
Happy testing!