Unit Testing with async/await
Write robust and maintainable software using modern language features
Welcome to Part III of our async testing epic!
After comprehending the ancient wisdom of dependency injection; and adding the mysteries of mocking to your toolbelt; our first dip into asynchronous unit testing will feel like a breeze.
Part II Mocking like a Pro
Part III Unit Testing with async/await
Part IV Advanced async testing: Unstructured concurrency
Part V (interlude) - Implementing the Repository pattern with Combine
Part VI Combine, async/await, and testing
Part III - Unit Testing Async Code
Let's harness our freshly-acquired skills to write our first async tests.
Fortunately, async/await is specially designed to make asynchronous code look and feel exactly the same as synchronous code - so our first tests with them are very straightforward.
We're going to test 3 common behaviours scenarios in this post:
Is my code properly talking to the dependencies it depends on?
How is my code dealing with successful function calls?
How is my code handling error states that result from function calls?
Setting up our first tests with callCount
As I mentioned in Part 2, we’ve set our mocks up to count the number of times each method is called, like a jobsworth club bouncer and his clicker. Some of the simplest tests are callCount
tests - we’re essentially asking our code to answer this question:
When I call this function, does the service on which my class depends get called?
Here's the first such test: found in the BeerViewModelTests in my Bev project.
Let's step through the code line-by-line to make sure we all understand:
1. In Swift's XCTest, you need to prefix any test method with test
. We're also marking this function async
because it contains an asynchronous method call. If you leave this out, XCode flags up a handly compiler error.
2. We've set our mockBeerRepository
up to fail if we call any methods without setting a stub (either a value or an error) - so here, we simply tell it to return an empty array to us when we call loadBeers
.
3. This is where the magic happens! We call loadBeers()
on the system under test - AKA the sut
- our instance of BeerViewModel
. Since this is an async method, we prefix with await
.
4. Finally, to make this a proper test, we need to make assertions. Since we called sut.loadBeers()
once, it should have in turn called loadBeers()
on the repository. Therefore, we expect that the mock's loadBeersCallCount
property is equal to 1.
Now we've understood our first test, writing another similar test in the BeerRepositoryTests is quite straightforward:
These look very similar to the view model - except that we're using a dependency-injected mock of the BeerAPI
; and our sut
is the Repository this time around.
If you like, take a proper look at the full files for BeerViewModelTests and BeerRepositoryTests to see the full setup logic and take another look at the mocks we're setting up.
Testing succces and failure states
These tests are the real meat-and-potatoes of unit testing. We're going to focus on writing tests for the `BeerAPI` in this section - check out BeerAPITests in my sample project for the full code.
We often start writing our code to deal with the happy path - when everything goes according to plan - we get the data we want, and process it successfully.
Of course, unfortunately, we live in the real world.
Perhaps we do simply get the data we want. But oftentimes, a user is offline. Our function could succeed, but find no actual data. Finally, we need to expect the unexpected - and handle unexpected error cases.
If we have tests which verify how our code deals with all the possible scenarios, then we have confidence that the code we're shipping to our users is robust and maintainable.
In BeerAPI, the getBeers()
method asks our URLSessionProtocol
to request the beer data from the network at the api.punkapi.com endpoint. The URLSessionProtocol is URLSession.default
in our production code, but our tests set it up with the MockURLSession
.
The function signature getBeers()
looks like this:
public func getBeers() async throws -> [Beer]
This tells us it's asynchronous, that we can expect it to return an array of beers, and finally that it might throw an error for us to handle.
Testing the success state
Our first test makes sure that we can get the array of beers we're asking for, along the happy path:
In lines 3-7, we're setting up an array of beers to use as mock data.
In line 9, we take this mock
Beer
data and encode it into the SwiftData
type.In line 10, we set up our mock as before. Since this is a mock for
URLSession.data(from url: URL)
, we need to stub out a full tuple for the return type:(Data, URLResponse)
, but can ignore the URLResponse here. We're just telling the mock to return thebeerData
we just created whendata(from url: URL)
is called.In line 11, we're calling the function on our API. Remember, here,
sut
is ourBeerAPI
class. As always, weawait
here to ensure we properly wait for the asynchronous method to return before continuing to our assertions.Finally, in line 12, we assert that the
Beer
objects returned by our API match what we expect.
Testing the success state with empty data
The next scenario is very similar to the happy path, and dead simple if you were paying attention:
The only difference here is instead of instantiating a full array of Beer
, we have an empty array. We encode it, tell the API to return this empty array as data, and then check that the API succeeds in returning this empty array.
Failure state with an anticipated error
This is where things get interesting. To make sure our code is fully robust, we want to check that our code is dealing with common error cases in the way we like. Since the users of our Bev app might be deep in a bar, their network signal might not be reliable. To handle this, I'm going to amend my code to ensure we have a special case to handle this common error.
When the consumers of my API class get this error, they can handle it by implementing retry logic (e.g. polling or exponential backoff); by falling back to cached data; or simply by showing an alert to the user.
First, in line 2, we are setting up our expected value; except this time around, we're expecting an error.
NSURLErrorNotConnectedToInternet
is an alias for the iOS system error code returned byURLSession.data(from url: URL)
when the user is offline.In line 3, we're telling the URLSession to return a
Result.failure
with this error wrapped inside.In lines 4-6, we're calling the function on our
su
e, except we tell XCTest to fail the test if thedo/catch
block ends in a success.Finally, we catch the error in lines 8-10 and assert that it's the type we expect to have returned by our method.
Failure state with an unepected error
My networking logic depends on URLSession, which has literally hundereds of ways it can fail. Instead of dealing with each case individually, eventually you'll need to end your API method with catch { throw error }
, which acts as a catch-all to forward your error.
To make life simple, let's set up a simple error enum in our test code:
Now, we can write one last test:
This follows the exact same format as before, except the stub is set up with this new error and we're checking for the TestAPIError
in our assertion.
Since it's not exactly async-testing-related, I won’t get into it today, but you’re welcome to look through BeerAPITests.swift on GitHub to see how we achieve test coverage of our API:
test_getBeers_returnsEmptyArray()
test_getBeers_invalidURL_throwsCouldNotConstructURLError()
test_getBeers_invalidJSON_throwsDecodingError()
test_getBeers_emptyData_throwsDecodingError()
Conclusion
Congratulations! With the first 3 chapters under your belt, you've learned how to handle the majority of situations that call for asynchronous unit tests.
As a bonus, by making your code testable through dependency injection, it is far cleaner and easier to reason about, thanks to encapsulated logic and properly separated responsibilities.
You're ready to handle more advanced situations in Part IV - Advanced async testing: unstructured concurrency.
Part II Mocking like a Pro
Part III Unit Testing with async/await
Part IV Advanced async testing: Unstructured concurrency
Part V (interlude) - Implementing the Repository pattern with Combine
Part VI Combine, async/await, and testing