4 tips to master XCTe
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
processing and ends up with
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,
didCall_didRequestOpeningLevelWithIdentifier block when the delegate method is called. The View Model is configured with two levels:
.locked, stored respectively on indexes
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 in
SecureStorage only when the storage is unlocked. We want to test that what we read from storage is what we previously saved.
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
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
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.
Want to reach me directly? I’m ncreated on Twitter.