Testing Asynchronous Tasks

Luis Piura
Feb 15 · 7 min read
Photo by Startup Stock Photos from Pexels

Learning outcomes

  1. Learn how to use XCTestExpectation clas
  2. Take advantage of XCTestExpectation class to clean up the source code of your tests

XCTestExpectation class

When test driving or writing tests for some tasks we usually have our given, our when, and finally our then. This is easy to picture when we create tests for synchronous tasks. What happens when we have to test driving tasks that don’t behave the same way? If a task is asynchronous, How do we get the results to make our assertions in our test? The answer is fairly simple, Apple provides us with XCTestExpectation class. According to Apple documentation, this class represents:

In simpler words, this class will help us to know if an asynchronous task did complete or fulfill. Expectations fulfill when:

  1. They receive a notification,
  2. They receive an object that is successfully evaluated with a predicate,
  3. They observe a value until it matches an expected value, or;
  4. When as a developer you explicitly fulfill them(The ones we’ll cover in this tutorial 🙂).

The XCTestCase class provides us with the expectation method to create XCTestExpectation objects. Let’s see an example:

func test_asyncFunction_didFulfill() {
// Given
//Any initialization
//When
let exp = expectation(description: "Wait for asycn function to complete")
//Then
//Any assertion
}

In the example above we create an expectation that must be explicitly fulfilled. Why? Because we only passed the description parameter. The expectation method is overloaded to cover the four ways to fulfill an expectation we’ve seen before.

Now you may be wondering, What does explicitly fulfill mean? This means that you call the fulfill method when your async task is completed. You do this to let your test know that an async task has already completed its execution.

exp.fulfill()

You may also be wondering: Hmm I’m calling an async task from a synchronous function, what happens if my async task takes a while? Can my test function end before my async task? If I don’t get the async task result on time, How can I do some assertions? This problem is solved with the wait method provided by the XCTestCase class as well. This method will receive an array of expectations and a timeout in seconds, meaning that your test function will be waiting until the expectations you passed are fulfilled. We define our timeout value based on the task we will perform. Maybe 3–5 seconds would fit for async tasks that require network operations such as an URL request.

func test_asyncFunction_didFulfill() {
let exp1 =
expectation(description: "Wait for async function to complete")
let exp2 =
expectation(description: "Wait for another async function to complete")
//Async actions call // Our function is on hold and waiting for the expectations to be fulfilled when it reaches this line
wait(for: [exp1, exp2], timeout: 2.0)
//Once our expectations are fulfilled our function continues its execution to this block}

If by any reason the expectations we declared in the previous example didn’t fulfill our test would fail and we’d receive the next message:

Another important thing about the wait method, is that it doesn’t matter in which order the expectations are fulfilled. If your test requires the expectation to fulfill in a certain order you could use the next method overload:

func wait(for expectations: [XCTestExpectation], 
timeout seconds: TimeInterval,
enforceOrder enforceOrderOfFulfillment: Bool)

The Authenticator class example

The Authenticator class will help us to understand better the dynamics described before. Our class is very simple an it only has one signIn method. The signIn method will execute an async task and it has one sad path and one happy path meaning that we will write a test for each one of them.

class Authenticator {   enum Result: Equatable {
case success
case fail(Error)
}
enum Error: Swift.Error {
case imcompleteData
}
func signIn(email: String, password: String, completion: @escaping (Result) -> Void) { guard !email.isEmpty, !password.isEmpty else {
completion(.fail(.imcompleteData))
return
}
completion(.success)
}
}

Let’s start with the sad path test, signIn method delivers an incompleteData error if either the email or the password are empty.

func test_signIn_withEmptyEmailOrPasswordDeliversIncompleteDataError() {   //Given
let sut = Authenticator()
let email = ""
let password = ""
//When
authenticator.signIn(email: email,
password: password) { result in
}
}

In the previous example, we have our given and our when so far but we are still missing a few things. Our signIn function is asynchronous meaning that we require to add an expectation and wait for this expectation to fulfill. Now the questions are:

  1. Where do we fulfill our expectation? and
  2. Where do we wait for our expectations to complete?

We know that the singIn function completes when the closure we passed as a parameter is executed, therefore, the closure is the place from where we will fulfill our expectation.

We will wait for our expectations to fulfill right after the asynchronous function invocation by calling the wait method. The wait method “freezes “ our source code until the expectations you passed as a parameter fulfill. If we call this method before our async function we will receive a timeout error in our test.

func test_signIn_withEmptyEmailOrPasswordDeliversIncompleteDataError() {   //Given
let sut = Authenticator()
let email = ""
let password = ""
//When
//Expectation declaration

let exp =
expectation(description: "Wait for async signIn to complete")
authenticator.signIn(email: email,
password: password) { result in
//Fulfill expectation from signIn completion block
exp.fulfill()
}
//Freeze the function execution until we fulfill our expectation
//This could take a second or less

wait(for: [exp], timeout: 1.0)
}

Great! We have a passing test and we ensure that we won’t get any timeout error because we are fulfilling our expectation. We are still missing our assertions to ensure we have a valid test. We will add out assertions in the singIn completion block (That will work… for now 🙂).

func test_signIn_withEmptyEmailOrPasswordDeliversIncompleteDataError() {   //Given
let sut = Authenticator()
let email = ""
let password = ""
//When
let exp =
expectation(description: "Wait for async signIn to complete")
authenticator.signIn(email: email,
password: password) { result in
//Then
XCTAssertEqual(result, .fail(.imcompleteData))
exp.fulfill()
}
wait(for: [exp], timeout: 1.0)
}

Great! We cover the sad path with this test. Now let’s add a test to cover the happy path.

func test_signIn_withEmailAndPasswordAuthenticatesTheUser() {
//Given
let
sut = Authenticator()
let email = "my-email@my-domain.com"
let password = "my-password"
//When
let
exp =
expectation(description: "Wait for async signIn to complete")
authenticator.signIn(email: email,
password: password) { result in
//Then
XCTAssertEqual(result, .success)
exp.fulfill()
}
wait(for: [exp], timeout: 1.0)
}

Great now we have passing tests for both paths! But it seems to me that we are repeating ourselves and that we could improve both tests. As you can see both test functions are:

  1. Declaring an expectation,
  2. Calling the signIn method,
  3. Waiting for the expectations to fulfill, and finally,
  4. Do some assertions.

This means that we could extract all of those tasks in a separate function. Our function will receive our SUT(System under test) which in this case is an Authenticator instance, the email, the password, and finally the expected result. Due to we will do some assertions in this function we must pass the file and line as well for XCode to show us the correct line that may fail.

func test_signIn_withEmailAndPasswordAuthenticatesTheUser() {
let email = "my-email@my-domain.com"
let password = "my-password"
expect(Authenticator(),
email: email,
password: password,
toCompleteWith: .success)
}
func test_signIn_withEmptyEmailOrPasswordDeliversIncompleteDataError() {
let email = ""
let password = ""
expect(Authenticator(),
email: email,
password: password,
toCompleteWith: .fail(.imcompleteData))
}
// MARK: - Helperprivate func expect(_ sut: Authenticator,
email: String,
password: String,
toCompleteWith expectedResult: Authenticator.Result,
file: StaticString = #filePath,
line: UInt = #line) {
let exp =
expectation(description: "Wait for async signIn to complete")
sut.signIn(email: email, password: password) { receivedResult in
XCTAssertEqual(expectedResult,
receivedResult,
"Expecting \(expectedResult) got \(receivedResult) instead",
file: file,
line: line)
exp.fulfill()
}
wait(for: [exp], timeout: 1.0)
}

Now our tests are cleaner and it’s easier to understand what they are doing!

There’s also another approach that we can follow. Imagine that we want to do the assertions from the test functions instead. We could create a helper method to return the signIn result. This sounds like an impossible task, isn’t it? Call an asynchronous function and then return its value. Do you remember we mentioned that calling the wait method would freeze your code? Well, that’s what will help us to get the signIn result and return it.

func test_signIn_withEmailAndPasswordAuthenticatesTheUser() {
let email = "my-email@my-domain.com"
let password = "my-password"
let receivedResult =
resultFor(Authenticator(), email: email, password: password)
XCTAssertEqual(receivedResult, .success)
}
func test_signIn_withEmptyEmailOrPasswordDeliversIncompleteDataError() {
let email = ""
let password = ""
let receivedResult =
resultFor(Authenticator(), email: email, password: password)
XCTAssertEqual(receivedResult, .fail(.imcompleteData))
}
// MARK: - Helperprivate func resultFor(_ sut: Authenticator,
email: String,
password: String) -> Authenticator.Result? {
let exp =
expectation(description: "Wait for async signIn to complete")
var result: Authenticator.Result?
sut.signIn(email: email, password: password) { receivedResult in
result = receivedResult
exp.fulfill()
}
wait(for: [exp], timeout: 1.0) return result}

We still have passing tests for both paths! As you can see the resultFor function calls the singIn function and then waits for the expectation to fulfill. Once the expectation fulfills the function can continue its execution to the return result line.

Both approaches are valid to use and help us to keep our test source code cleaner.

Geek Culture

Proud to geek out. Follow to join our +500K monthly readers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store