Introduction
In the article Unit Testing in Swift, we covered how to get started with unit testing using examples largely written for synchronous behaviour. In your journey as an iOS developer, however, you’ll face scenarios where the functions in your app must wait for data to be fetched. In this article, we’ll look at how you can use asynchronous testing in Swift for these types of scenarios. We will write tests for functions that return data after a variable amount of time.
In this introductory article about asynchronous testing, we’ll cover the following:
- Issues with normal Unit testing
- How to write asynchronous tests
- Using XCTestExpectation
- Mocking services
- Testing async/await code
So let’s get started!
Issues with normal tests
When we’re writing a Unit test, we have defined input and output and the test passes successfully after the method returns the expected output. However, in the case of asynchronous code, it might return the expected output after the test has finished executing, leading to a flaky test.
Let’s assume we’re fetching the list of the tallest towers using a network request. We want to test and make sure that the list we get back is not empty:
func testTallestTowersShouldNotBeEmpty() { TowersDataManager.tallestTowers { result in
switch result {
case .success(let towers): XCTAssert(towers.count > 0, "tallestTowers list is empty")
case .failure(let error):
XCTFail("Expected towers list, but failed \(error).")
}
}
}
The execution of the completion handler happens after the test finishes executing because it is a network call working asynchronously. We don’t know if our assertion failed or not, as the test always passes. We can verify this by putting a breakout point on the XCTAssert line, which is not executed.
Asynchronous testing: how to write asynchronous tests
To overcome the challenges mentioned above, we want to wait for the data and then test it; or mock/mimic the method to work as synchronous code. For the former, Apple provides us with a class called XCTestExpectation
in the XCTest framework. For the latter, we can create a mock service for our networking class.
Using XCTestExpectation
To test asynchronous code, we use the XCTestExpectation
class and wait for the expected outcome. The workflow is to create an expectation, and then when the asynchronous task completes successfully, we fulfill that expectation. We will wait for a specific amount of time for the expectation to be fulfilled. If the timeout is exceeded before the expectation is fulfilled, we get a failure error.
To create a new expectation, we need to provide a description:
let expectation = XCTestExpectation(description: #function)
After receiving the data, call the fulfill()
method to mark the expectation as being met:
expectation.fulfill()
After the closure, call the wait(for:timeout:)
method for the specific expectation, with the desired timeout in seconds:
wait(for: [expectation], timeout: 3.0)
Taking the example from above, here’s the updated code:
func testTallestTowersShouldNotBeEmptyy() {
let expectation = XCTestExpectation(description: #function) var tallestTowers: [Tower] = [] TowersDataManager.tallestTowers { result in
switch result {
case .success(let towers):
tallestTowers = towers expectation.fulfill() case .failure(let error):
XCTFail("Expected towers list, but failed \(error).") }
}
wait(for: [expectation], timeout: 3.0) XCTAssert(tallestTowers.count > 0, "tallestTowers list is empty") }
The tallestTowers
method uses URLSession's dataTask
method, which creates a background data task. It fetches the JSON data from the specified URL in a background thread. The main thread waits for the expectation to be fulfilled while the task is running in the background.
After receiving a successful result, we update the tallestTowers
variable with the value decoded from our method. Then, we fulfill the expectation, indicating that the task has been completed. If the task completes before the specified timeout, the test moves to assertion and tries to assert that the count of tallestTowers
is greater than zero.
By giving a meaningful function name, we get a clear failure description:
Exceeded timeout of 3 seconds, with unfulfilled expectations: “testTallestTowersShouldNotBeEmpty() “.
There are three ways in which this asynchronous test might fail:
- If it triggers the
XCTFail
method in the event of a thrown error. - If the timeout expires before the completion handler is called, due to network or other issues.
- If the data is returned successfully, but the list is empty.
This approach helps us cover the cases that we need to in an asynchronous test!
XCTestExpectation
also provides us with a few instance properties for more control over our tests. One of them is:
**isInverted**
: For cases where you want to make sure that a given situation does not occur.
For example, imagine that you’re offering premium content to your users and want to make sure that the premium content data won’t get fetched unless the user is subscribed. We create an expectation and set its isInverted
property to true. The test will fail if the content is fetched within the specified time. The expectation will be fulfilled when the unexpected situation occurs, i.e. when no request occurs within the given timeout if the user hasn't subscribed.
let expectation = expectation(description: #function) expectation.isInverted = true
There are four other classes that subclass XCTestExpectation
for different use cases:
Mocking services
While we can test asynchronous code using expectations, it is time-consuming and slow considering that we depend on a network connection that could eat up our entire delay time. Multiplying this by 10s of daily tests will significantly increase build times for your CI/CD service.
Another way of running asynchronous tests is to mock the network code and avoid an actual network request. We can mimic the behavior of an asynchronous test, but with predefined inputs and outputs.
We’ll create two versions of the request-one actual and one mocked. For this, we’ll single out the method found in both, i.e. the method that performs the request:
protocol TowersNetworkSessionProtocol {
func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ())
}
We create two classes conforming to TowersNetworkSessionProtocol
:
class TowersNetworkSession: TowersNetworkSessionProtocol {
func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ()) { guard let url = url else { completion(.failure(TowerNetworkError.invalidURL)) return
} URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
} if let data = data {
completion(.success(data))
} else {
completion(.failure(TowerNetworkError.missingData))
}
}
.resume()
}
}
This network session performs the real network request. Now, we’ll create another one, mimicking this behaviour:
class TowersMockNetworkSession: TowersNetworkSessionProtocol {
var completion: Result<Data, Error>? func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ()) {
guard url != nil else { completion(.failure(TowerNetworkError.invalidURL))
return
} self.completion.map(completion)
}
}
This mock class contains an instance property called completion
that we can set while testing for successful responses and errors.
Now, the main class accepts TowersNetworkSessionProtocol
in its initialiser:
class TowersDataManager {
private let session: TowersNetworkSessionProtocol init(session: TowersNetworkSessionProtocol) {
self.session = session
} func tallestTowers(completion: @escaping (Result<[Tower], Error>) -> ()) {
let url = URL(string: "https://tower.free.beeceptor.com/tallest") session.execute(url: url) { result in
switch result {
case .success(let data):
let result = Result(catching: {
try JSONDecoder().decode([Tower].self, from: data)
})
completion(result)
case .failure(let error):
completion(.failure(error))
}
}
}
}
We’ve set up the mock environment. Time to test it out!
func testTallestTowersData() throws {
// 1
var result: Result<[Tower], Error>? // 2
let tallestTowers: [Tower] = Tower.tallestTowers
let response = try JSONEncoder().encode(tallestTowers) // 3
let session = TowersMockNetworkSession() session.completion = .success(response) // 4
let dataManager = TowersDataManager(session: session) // 5
dataManager.tallestTowers { result = $0
} // 6
XCTAssertEqual(try result?.get(), tallestTowers)
}
While writing out our code, we:
- Declare a result type that we’ll fill with the result from the completion handler.
- Use hardcoded data and encode it into JSON format.
- Create an object of the mock class and set the completion variable to register success with the desired response.
- Instantiate a data manager class passing the mock session as the argument.
- Call the
tallestTowers
method. Note that this does not hit the server; it just passes the completion handler. - Assert to check if the obtained result and the hard-coded values are the same. If the JSON was parsed correctly, our test should pass.
We can also test if the method correctly throws a missing data error so we can handle it in the app. Let’s create another test to do that:
func testTallestTowersForMissingData() {
var result: Result<[Tower], Error>? let session = TowersMockNetworkSession()
session.completion = .failure(TowerNetworkError.missingData) let dataManager = TowersDataManager(session: session) dataManager.tallestTowers {
result = $0
}
XCTAssertThrowsError(try result?.get()) { error in
XCTAssertEqual(error as? TowerNetworkError, .missingData)
}
}
We set the completion of the session as a failure with the error as missing data. Using XCTAssertThrowsError
, we get an error while fetching the result and assert that it is equal to the missingData
error.
This way, we can test if our networking manager is working as expected!
Expanding asynchronous testing: testing async/await code
This year, Apple introduced the async/await pattern in Swift 5.5. It is a way of writing asynchronous code in a synchronous way.
Using the previous example, let’s re-write our test. Start off by adding a method in TowersNetworkSessionProtocol
:
protocol TowersNetworkSessionProtocol {
func execute(url: URL?, completion: @escaping (Result<Data, Error>) -> ())
func execute(url: URL?) async throws -> Data
}
Then, add the requirement in the other two classes:
class TowersNetworkSession: TowersNetworkSessionProtocol {
func execute(url: URL?) async throws -> Data {
guard let url = url else {
throw TowerNetworkError.invalidURL
} let (data, _) = try await URLSession.shared.data(from: url)
return data
}
} class TowersMockNetworkSession: TowersNetworkSessionProtocol {
func execute(url: URL?) async throws -> Data {
guard url != nil else {
throw TowerNetworkError.invalidURL
} guard let data = try completion?.get() else {
throw TowerNetworkError.missingData
}
return data
}
}
The new async/await syntax makes it easier to express asynchronous code in a synchronous flow. To test it, we don’t have to use expectations:
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
func testTallestTowersShouldNotBeEmptyAsync() async throws {
let session = TowersNetworkSession()
let dataManager = TowersDataManager(session: session) do {
let tallestTowers = try await dataManager.tallestTowers()
XCTAssertTrue(tallestTowers.count > 0, "tallestTowers array is empty")
} catch {
XCTFail("Expected towers data, but failed \(error).")
}
}
You can see that the code to test the async/await syntax is comparable to writing a standard unit test, with the only difference being the use of the async and await keywords.
The previous test hits the actual network call, so we also have to write a mock test for this async method:
func testTallestTowersDataAsync() async throws {
let tallestTowers = Tower.tallestTowers
let response = try JSONEncoder().encode(tallestTowers) let session = TowersMockNetworkSession()
session.completion = .success(response) let dataManager = TowersDataManager(session: session)
let result = try await dataManager.tallestTowers() XCTAssertEqual(result, tallestTowers)
}
The code is simple to follow. We take hardcoded data and set it as the completion of the network session. We call the tallestTowers()
method asynchronously and wait for the result. Then, we compare the result with the hardcoded data.
To test missing data, we can use the following:
func testTallestTowersForMissingDataAsync() async {
let session = TowersMockNetworkSession()
session.completion = .failure(TowerNetworkError.missingData) let dataManager = TowersDataManager(session: session) do {
_ = try await dataManager.tallestTowers()
XCTFail("Expected to throw missing data error, but succeeded.")
} catch {
XCTAssertEqual(error as? TowerNetworkError, .missingData)
}
}
Remember, we want the test to fail and throw a missing data error. In this case, the test fails if it succeeds.
Conclusion
Asynchronous testing is more difficult than writing trivial unit tests. While this article primarily focuses on asynchronous testing of network requests, the same logic can be applied to test delegate methods, file operations, or notifications.
Originally published at https://semaphoreci.com on October 13, 2021.