4 tips to master XCTestExpectation
I believe that what makes us good programmers is a desire to master and improve the toolset we all have. With this in mind, one shiny morning at BlaBlaCar’s office in Warsaw I stopped for a while to look at how I can squeeze more from the old good XCTestExpectation
. In this article I will show you a few juicy 🍹 things we now use to write our unit tests more efficiently.
These 4 tips can be applied to testing asynchronous code. I’m going to illustrate my thoughts with practical examples from different domains.
Tip 1: Fulfill one expectation multiple times
Sometimes it’s necessary to test that a given asynchronous code is executed multiple times. The .expectedFulfillmentCount
makes it more than easy. It represents the number of times fulfill()
must be called before the expectation is completely fulfilled.
Example: Consider a Task
object that can be started by calling execute()
. It reports status updates to the delegate object. We want to test that once the task is started, its status changes asynchronously to downloading
, processing
and ends up with finished
:
MockTaskStatusDelegate
executes didCall_taskDidChangeStatus
block to confirm that the appropriate delegate method was called. In this closure (line 21) the “delegate is called 3 times” is fulfilled. We use helper array of recordedStatuses
to track status history and assert its correctness after expectation is completed.
Tip 2: Invert the expectation to assert it’s never fulfilled
To test that a given code is not executed, the .isInverted
property can be used. An “inverted” expectation will fail if fulfilled. It is useful when testing mutually exclusive flows or simply when a given thing should happen with one configuration but not with another.
Example: Imagine we build a game, specifically the level selection screen. Some levels are available and others are locked. Tapping on a button from the first group starts the play, where tapping on latter does nothing — and this is the behavior we want to test.
Technically, the screen is represented by LevelSelectionViewModel
. It exposes selectLevel(atIndex:)
method called from view when user taps the button. LevelSelectionViewModelDelegate
should be notified with didRequestOpeningLevel(withIdentifier:)
when the game should begin:
Similarly to the previous example, MockLevelSelectionViewModelDelegate
executes didCall_didRequestOpeningLevelWithIdentifier
block when the delegate method is called. The View Model is configured with two levels: .unlocked
and .locked
, stored respectively on indexes 0
and 1
. The first unit test simulates tapping on index 0
and asserts that the expectation is fulfilled. The second test configures the expectation to be inverted (line 38), so it will pass only if line 41 is not executed within given timeout.
It’s worth noting that the second test is more valuable if companioned by the first one. The first test ensures that the View Model talks to its delegate, while the second one limits this logic to .unlocked
buttons only. Having only the latter could give us a false sense of correctness. Imagine for instance, that there is a bug in LevelSelectionViewModel
causing the delegate to never be called. The second test will pass (the inverted expectation will not be fulfilled). Without the first test we might think that everything is fine, but it’s not. In such example, the first test will fail because its expectation won’t be fulfilled.
Tip 3: Use multiple expectations and selectively wait for some of them
Expectations do not necessarily have to be verified at the end of the test method. This can be done earlier, using this handy XCTestCase function:
func wait(for: [XCTestExpectation], timeout: TimeInterval, enforceOrder: Bool)
— Waits on an array of expectations and specifies whether they must be fulfilled in the given order.
If the test runner calls wait(for:timeout:)
when executing a test method, it blocks the execution and resumes it only after all given expectations are fulfilled. It can also guarantee the order if enforceOrder
flag is used.
Example 1: A secret can be changed inSecureStorage
only when the storage is unlocked. We want to test that what we read from storage is what we previously saved.
Because the unlock(completion:)
method completes asynchronously, we need to wait for it before calling save()
. Now, because saving is also an asynchronous operation, we wait until it completes before calling read()
.
Test execution will first pause at line 12, waiting until completion block is called in storage.unlock()
. Then it will be resumed, call save()
and wait for its asynchronous result in line 18. Finally, readExpectation
will prevent the test method from returning until read()
completion block is executed.
Example 2: Let’s get back to “Fulfillment count” example. We used an array to store Task
status values and assert an order ([.downloading, .processing, .finished]
). Can wait(for:timeout:enforceOrder:)
be a better choice?
Instead of fulfilling one expectation 3 times, three of them are used — each to assert one status value. By setting enforceOrder
to true
we make sure all are fulfilled in the right order.
Both ways get the job done, but with the latter it’s cleaner that the order is tested. We also don’t need an additional variable that might decrease the readability. Pick the way that better suits you.
Tip 4: Give the expectation a meaningful description
Last but not least: the expectationDescription
really matters! It’s made to help diagnose failures. The better description you provide, the easier it will be to understand your tests and maintain them.
I saw many strange conventions for expectationDescription
, all subtly convincing me that some developers are smart in their laziness 😉. Neither duplicating test method name, nor using #function
macro won’t help the reader to grasp your code or understand the failure report. If it’s hard to come up with a good description, maybe you are testing the wrong thing?
The perfect description tells the reader what the code is waiting for:
expectation(description: "delegate is called twice")
expectation(description: "secure storage is unlocked")
expectation(description: "user is logged in")
which gives a clear and neat failure report:
Exceeded timeout of 0.5 seconds, with unfulfilled expectations: “user is logged in”.
By applying these tips at BlaBlaCar we manage to write shorter and cleaner unit tests. We cover even more use cases and edge scenarios with easing maintainability at the same time. It’s a good example of how improving the toolset a bit can help boost productivity.
Get more
To read further on this topic I recommend looking at the documentation pages for XCTestExpectation and XCTestCase. Don’t hesitate to share your findings with me.
Want to reach me directly? I’m ncreated on Twitter.