跳转至

Chapter 6: Testing Asynchronous Code

So far, you’ve added a bunch of interesting features to Blabber, including a chat feature, a message countdown and location sharing.

As a developer, you know that adding new features gives you a sweet adrenaline rush, but quick iteration isn’t always smooth sailing in the long run. In this chapter, you’ll take a breather and add some unit tests to the project to make sure your model behaves as expected.

Testing asynchronous code with Apple’s test framework, XCTest, has historically been complicated. Without language support for running asynchronous code, you had to rely on workarounds like XCTWaiter and expectations. Additionally, you had to wait until the test under code was complete before you could verify its output.

img

From what you’ve learned so far in this book, you might think you need to do something complicated to make an asynchronous context within your testing code. Luckily, you don’t! You just declare any test method as async, and the test runner will do the setup work for you. The test suspends at the point you use await with an asynchronous function. Once it resumes, you can verify the output as usual:

img

As you see in the diagram above, the new syntax lets you write asynchronous tests linearly, as if they were synchronous. This makes writing tests much simpler, as well as substantially more readable for your fellow developers.

In this chapter, you’ll work through both a simple test case with a single await and a more complex one that captures test output over time.

Capturing network calls under test

Open the starter version of Blabber in this chapter’s materials, under projects/starter. Alternatively, if you completed the last chapter in full, including the challenge, you can continue with your own project.

Next, open BlabberTests.swift, where you’ll add your tests for the BlabberModel type. So far, there are no tests. No bueno!

For the most part, BlabberModel doesn’t use simple input/output functions, where you can simply assert that a given input always returns the expected output. Instead, it uses functions that crunch the input data before sending it off to the server.

The full chain of events looks like this:

img

Your goal now is to add asynchronous tests to verify that BlabberModel always sends correct data to the server.

Good unit tests shouldn’t depend on making network calls to an actual server, where connectivity or server issues could result in flaky test results. There are two common approaches to testing networking calls:

  • Injecting a mock URLSession-like type that captures requests on your tests’ behalf.
  • Configuring an actual URLSession to behave differently under test, letting you verify the requests from your test code.

In this chapter, you’ll work through the second option. Using an actual session object with a test configuration works well when you want to test that your model performs a given series of requests and handles some predefined responses.

You’ll add custom URL handlers to your networking stack via URLSession.configuration, which lets you do some nifty things. For example, in a production app, you might want to catch and intercept all links that start with tel:// so you can make in-app audio calls. Or you might custom-handle URLs starting with https://youtube.com to prevent your users from switching to the YouTube app.

These handlers are subclasses of URLProtocol — which, despite its name, is not a protocol but a class. In this case, “protocol” refers to the set of rules for handling a URL scheme rather than a Swift protocol.

For your tests in this chapter, you’ll intercept and record all network requests using a custom URLProtocol subclass:

img

Implementing a custom URLProtocol

Open Utility/TestURLProtocol.swift. Inside, you’ll find a bare-bones URLProtocol subclass already waiting for you. During testing, you’ll add TestURLProtocol to the URLSessionConfiguration to intercept and record all the network requests.

The minimum protocol requirements, which are already included in the code, are:

  • canInit(with:): Returns true when the current protocol should handle the given URLRequest. In this case, you always return true since you want to catch all requests.
  • canonicalRequest(for:): This method can alter requests on the fly. In this case, you simply return the given request with no changes.
  • startLoading(): Here, you load the request and send a response back to the client.
  • stopLoading(): Call this method when the operation is canceled or when the session should otherwise stop the request. For these tests, you don’t have to add anything here.

The starter code in startLoading() creates a successful server response with no content and returns it to the client. For these tests, you’re only interested in the outgoing requests, not what comes back from the server. You’ll also record the network requests here.

Next, add this new property to the TestURLProtocol type:

static var lastRequest: URLRequest?

Each time TestURLProtocol responds to a request, you’ll store it in lastRequest so you can verify its contents.

You probably noticed that the property is static. Because of the way you pass these URL protocols to URLSessionConfiguration, you can’t easily access instance properties, as you’ll see in a moment. For the simple tests in this chapter, this will do just fine.

Next, add the code to store each request at the bottom of startLoading():

guard let stream = request.httpBodyStream else {
  fatalError("Unexpected test scenario")
}

var request = request
request.httpBody = stream.data
Self.lastRequest = request

In this block, you take several steps:

  • First, you verify that the request has a non-nil httpBodyStream input stream. That’s the stream you use to read the request data.
  • You make a new mutable request variable so you can modify the request before storing it.
  • You read the request contents from httpBodyStream and store the data in httpBody.
  • Finally, you save the request in lastRequest so your tests can verify the contents after the network call completes.

That’s all it takes to complete your custom catch-all URL protocol. Now, you just need to use it to spy on what your app is sending.

Creating a model for testing

Switch back to BlabberTests.swift and add a new property in BlabberTests:

let model: BlabberModel = {
  // 1
  let model = BlabberModel()
  model.username = "test"

  // 2
  let testConfiguration = URLSessionConfiguration.default
  testConfiguration.protocolClasses = [TestURLProtocol.self]

  // 3
  model.urlSession = URLSession(configuration: testConfiguration)
  return model
}()

Here’s what the code above does:

  1. Create a new BlabberModel with the given username.
  2. Create a URL session configuration that uses TestURLProtocol to handle URL requests.
  3. Tell the model to use this new session.

TestURLProtocol will handle all the network calls made by this instance of BlabberModel so you can inspect them in your tests.

Now, it’s time to write a test!

Adding a simple asynchronous test

A critical point to remember when adding asynchronous tests is to add the async keyword to each test method. Doing this lets you await your code under test and easily verify the output.

Add the following method to BlabberTests to create your first test:

func testModelSay() async throws {
  try await model.say("Hello!")

}

Since the model is already configured to use the test-suitable URL session, you don’t need to do any additional setup — you just call say(_:) right away.

At this point, you’re ready to add your test expectations. First, you’ll verify that the last request the network performed, model.say("Hello!"), was sent to the correct URL.

Add the following code to do that:

let request = try XCTUnwrap(TestURLProtocol.lastRequest)

XCTAssertEqual(
  request.url?.absoluteString,
  "http://localhost:8080/chat/say"
)

You first unwrap the optional TestURLProtocol.lastRequest, then check that the URL matches the expected address: http://localhost:8080/chat/say.

Now that you’ve verified that the model sends the data to the correct endpoint, you can check that it also sends the correct data.

Finish up your test with the following piece of code:

let httpBody = try XCTUnwrap(request.httpBody)
let message = try XCTUnwrap(try? JSONDecoder()
  .decode(Message.self, from: httpBody))

XCTAssertEqual(message.message, "Hello!")

You expect request.httpBody to decode as a Message. Once decoded, you assert that the message text equals Hello!, as expected.

If you wrote asynchronous tests prior to Swift 5.5, you’re likely excited about the brevity and clarity of this test code. And if you haven’t written asynchronous tests before, you really don’t need to know the lengths you had to go to set up a good asynchronous test back then!

To run the test, click Play in the editor gutter, to the left of func testModelSay()..., or press Command-U to run all tests.

Regardless of how you go about it, you’ll see the test pass and a green check mark (the best check mark!) will appear next to the test name in Xcode:

img

Testing values over time with AsyncStream

Now that you’ve created a test that awaits a single value, you’ll move on to testing asynchronous work that may yield many values.

Start by adding another test to BlabberTests.swift:

func testModelCountdown() async throws {

}

As you already guessed, this test verifies if BlabberModel.countdown(to:) behaves as expected.

This time around, you’re in for a much more complex testing scenario, so be prepared to brace!

Note: Some tests are simply more challenging to design than others. If a given piece of code is difficult to test, that usually means you can improve the code itself — for example, by breaking it down into logical pieces and making it more composable. But sometimes, depending on the situation, tests are just complex. However, you’ll see that using async/await makes even complex tests easier to design.

Your say(_:) test was fairly simple because the method does a single thing and only sends a single network request:

img

countdown(to:), in comparison, is more involved. It sends up to four network requests, so you can’t verify only the last one in the sequence to guarantee the method works correctly:

img

This is really nice for you because it gives you the opportunity to use some of the new modern concurrency APIs.

Switch back to TestURLProtocol.swift. There, you store the last accepted request in lastRequest. Now, you’ll add a new function that returns a stream of all requests. You’ll then be able to call countdown(to:) and verify all the requests it sent.

To start, add the following code to TestURLProtocol:

static private var continuation: AsyncStream<URLRequest>.Continuation?

static var requests: AsyncStream<URLRequest> = {
  return AsyncStream { continuation in
    TestURLProtocol.continuation = continuation
  }
}()

This code adds a static property holding a continuation as well as a new static property, requests, which returns an asynchronous stream that emits requests.

You call finish() on the first line, just in case there’s an old continuation from a previous test. Then, you create a new AsyncStream and store its continuation.

You need to store the continuation so you can emit a value each time TestURLProtocol responds to a request. This is easy to handle — you just add a didSet handler to lastRequest.

Replace the lastRequest property declaration with this code:

static var lastRequest: URLRequest? {
  didSet {
    if let request = lastRequest {
      continuation?.yield(request)
    }
  }
}

Now, updating lastRequest will also emit the request as an element of the asynchronous stream that requests returns.

Great, these are all the changes you need to make in TestURLProtocol!

Completing the countdown test

Switch back to BlabberTests.swift and scroll to testModelCountdown(). It’s time to finally add your test code.

Add this code to testModelCountdown():

try await model.countdown(to: "Tada!")
for await request in TestURLProtocol.requests {
  print(request)
}

Here’s what the code above is doing:

  1. Make a call to countdown(to:).
  2. Iterate over the stream of requests to print the recorded values.

Run the test by clicking Play in the editor gutter:

img

Let the test run for a while… sadly, the execution never completes. The logs in Xcode’s output console prove that the test is hanging:

Test Suite 'Selected tests' started at 2021-09-02 13:53:33.107
Test Suite 'BlabberTests.xctest' started at 2021-09-02 13:53:33.108
Test Suite 'BlabberTests' started at 2021-09-02 13:53:33.109
Test Case '-[BlabberTests.BlabberTests testModelCountdown]' started.

As per the last log message, the test runner started testModelCountdown, but it never completed.

Next, add breakpoints on all three of the lines you just added and run the test again to verify where the execution stops:

img

The debugger stops on the first and second lines, but it never hits the breakpoint on print(request). The stream never emits any values.

What’s going on here? Look back at how you emit the requests: You only emit values when lastRequest is set. When your test starts the for await loop, countdown(to:) has already finished, so there are no requests to read.

It seems like you’ll have to scrap the current code and take a new approach. There’s one essential thing you should notice during this exercise:

await does not time out!

That means that if some of the tested code doesn’t behave correctly, your tests will just hang forever at some await suspension point.

This is not a problem with your test, per se. await simply doesn’t time out at all. If that turns into a problem in your code, you can fix this by adding some custom code to cancel your task if it takes longer than expected to complete.

You’ll take a quick detour from finishing testModelCountdown() and do just that — add the supporting infrastructure to your tests so they safely time out, instead of hanging forever.

Adding TimeoutTask for safer testing

You can’t let your tests hang indefinitely — that would defeat the purpose of verifying incorrect behavior. Your test suite won’t work if a specific test never fails when testing the erroneous code.

In this section, you’ll create a new type called TimeoutTask. This type is similar to Task except that it will throw an error if the asynchronous code doesn’t complete in time.

In the Utility folder inside BlabberTests, create a new file called TimeoutTask.swift.

Since you’ll use that file in your tests, take a moment after creating it to double-check that it only belongs to your test target. You can verify this under the Target Membership section in the File inspector on the right-hand side of the Xcode window while you have TimeoutTask.swift open:

img

If you haven’t checked the checkbox next to BlabberTests, do so now.

Next, replace all of the code in your new file with:

import Foundation

class TimeoutTask<Success> {

}

extension TimeoutTask {
  struct TimeoutError: LocalizedError {
    var errorDescription: String? {
      return "The operation timed out."
    }
  }
}

Here, you create a new type that is generic over Success, just like Swift’s Task is. Success is the type of result the task returns, if any. If the task doesn’t return a result, then Success is Void.

Additionally, you define a TimeoutError, which you’ll throw if the task times out.

With the basic setup out of the way, you can add the initializer for TimeoutTask, too, along with some useful properties:

let nanoseconds: UInt64
let operation: @Sendable () async throws -> Success

init(
  seconds: TimeInterval, 
  operation: @escaping @Sendable () async throws -> Success
) {  
  self.nanoseconds = UInt64(seconds * 1_000_000_000)
  self.operation = operation
}

The first parameter of your new initializer is the maximum duration in seconds, which you convert to nanoseconds and store. The second parameter is operation, which is (deep breath…) an escaping, thread-safe, asynchronous, throwing closure.

To go through all of those keywords:

  • @escaping: Indicates that you may store and execute the closure outside of the initializer’s scope.
  • @Sendable: You can’t conform to protocols for closures or function types in the same way that you can with other types. This new keyword indicates that a closure or function type conforms to the Sendable protocol, meaning it’s safe to transfer between concurrency domains.
  • async: Hopefully, you’re familiar with this term by now. It means the closure should execute in a concurrent asynchronous context.
  • throws: The closure can throw an error.

That’s a cumbersome set of keywords, but they all help the compiler and the runtime clearly understand your intentions and run your code correctly.

The initializer doesn’t do anything other than storing its values. Here, it differs from Task, which starts executing immediately.

Note: You’ll learn more about the Sendable protocol and the @Sendable annotation for function parameters in Chapter 8, “Getting Started With Actors”.

Starting the task and returning its result

Next, you’ll add a property called value, which will start the work and asynchronously return the result of the task. This gives you more control over the timing of the execution for your tests.

Add the following code to TimeoutTask:

private var continuation: CheckedContinuation<Success, Error>?

var value: Success {
  get async throws {
    try await withCheckedThrowingContinuation { continuation in
      self.continuation = continuation
    }
  }
}

As you’ve done in previous chapters, you declare the value getter as async and throws so you can control execution asynchronously.

Inside the getter, you start by calling withCheckedThrowingContinuation(_:) to get a continuation. This lets you either complete successfully or throw an error if the operation times out.

Once you get the initialized continuation, you store it in the instance property called continuation.

To start implementing the execution logic, add this task immediately after storing the continuation, while still in withCheckedThrowingContinuation’s closure:

Task {
  try await Task.sleep(nanoseconds: nanoseconds)
  self.continuation?.resume(throwing: TimeoutError())
  self.continuation = nil
}

Here, you start an asynchronous task that sleeps for the given number of nanoseconds — the timeout duration you use when creating a TimeoutTask. You then use the stored continuation to throw a TimeoutError().

So far, so good — you’ve implemented the part of the code that times out. Now, immediately after the previous Task, add the code that does the actual work:

Task {
  let result = try await operation()
  self.continuation?.resume(returning: result)
  self.continuation = nil
}

In this asynchronous task, you execute the initial operation closure. If that completes successfully, you use continuation to return the result.

You start two asynchronous tasks in parallel and let them race towards the final. Whichever task completes first gets to use the continuation, while the slower task gets canceled.

img

Note: On a rare occasion, it’s possible that both tasks might try to use continuation at precisely the same time — leading to a crash. You’ll learn about Swift’s actor type and writing safe concurrent code in later chapters. For now, leave the TimeoutTask code as-is.

Canceling your task

To wrap up your new type, you’ll add one more method: cancel(). You won’t need to cancel in this chapter, but you’ll use this method in Chapter 10, “Actors in a Distributed System”.

Inside TimeoutTask, add:

func cancel() {
  continuation?.resume(throwing: CancellationError())
  continuation = nil
}

The new method uses the stored continuation and throws a CancellationError(), like Apple’s own asynchronous APIs do when they’re canceled.

To try your new task, switch back to BlabberTests.swift and wrap the for await loop inside testModelCountdown() in a TimeoutTask, so it looks like this:

try await TimeoutTask(seconds: 10) {
  for await request in TestURLProtocol.requests {
    print(request)
  }
}
.value

As before, you call countdown(to:) and then iterate over requests — but this time, you wrap the latter inside a TimeoutTask with a maximum duration of ten seconds. You’ll also notice you’re actually awaiting the task’s value property, which holds all of the timeout logic you just worked on.

If you still have breakpoints on the test suite, turn them off. Then, run testModelCountdown() one more time. After a while, you’ll see the test fail:

img

Congratulations, you now have your own Task alternative that allows you to write safer asynchronous tests!

Sadly, this indisputable victory does not resolve your initial problem. Even though the test doesn’t hang anymore, it still fails. And, to finally be able to ship your progress into your (hypothetical) code repository, your tests need to pass.

Using async let to produce effects and observe them at the same time

If you remember, the reason the test hangs is that the operations take place in order, and the countdown finishes before you start reading the stored request stream.

You already learned how to start multiple asynchronous tasks and execute them in parallel in Chapter 2, “Getting Started With async/await.” You need to make multiple async let promises and await them all. That’s what you’ll do in this test.

Replace the contents of testModelCountdown() one last time with:

async let countdown: Void = model.countdown(to: "Tada!")

Since countdown(to:) doesn’t return a value, you need to explicitly define the promise type as Void. You’ll use countdown in a while to await the countdown method along with the task that will observe the recorded network requests.

Now, for the second promise:

async let messages = TestURLProtocol.requests

If you think about it, you don’t really need all the elements in requests. You only need as many as you expect during a successful run of countdown(to:). That means you need four requests, one for each message sent to the server.

Simply add this as the next line, just like you would for a regular Swift sequence:

.prefix(4)

Because you expect four requests, you take only four elements in the sequence. To collect these four into an array, add one more function call:

.reduce(into: []) { result, request in
  result.append(request)
}

reduce(...) runs the given closure for each element in the sequence and adds each request to result. Now, you can process the elements as any plain, old collection.

Now, add the following below:

.compactMap(\.httpBody)
.compactMap { data in
  try? JSONDecoder()
    .decode(Message.self, from: data)
    .message
}

In this code, you:

  • Grab httpBody from each of the requests, if it’s available.
  • Try to decode the body as a Message.
  • Return the message property as the result.

Long story short, you collect all the text messages in the messages array, like so:

img

The code, however, still hangs if you only get three requests instead of the expected four. The execution will stop at prefix(4) and wait for a fourth element.

You need to wrap your messages promise in a TimeoutTask, so messages ends up looking like this:

async let messages = TimeoutTask(seconds: 10) {
  await TestURLProtocol.requests
    .prefix(4)
    .reduce(into: []) { result, request in
      result.append(request)
    }
    .compactMap(\.httpBody)
    .compactMap { data in
      try? JSONDecoder()
        .decode(Message.self, from: data).message
    }
}
.value

With the two promises ready, the only thing left to do is await them concurrently and verify the output.

Add the following line to await the messages:

let (messagesResult, _) = try await (messages, countdown)

You don’t care about the result of countdown, so you only store messagesResult.

Finally, verify the contents of messagesResult:

XCTAssertEqual(
  ["3...", "2...", "1...", "🎉 Tada!"], 
  messagesResult
)

Run testModelCountdown() once more. This time around, it passes with a green check mark. Fantastic work!

Even though the code is now tested per se, there’s one aspect of asynchronous testing that might quickly turn into a problem as your test suite grows. The two unit tests that you just added take over five seconds to complete!

Who has the time to wait for hundreds or thousands of such tests?

Speeding up asynchronous tests

For both synchronous and asynchronous tests, you often need to inject mock objects that mimic some of your real dependencies, like network calls or accessing a database server.

In this last section of the chapter, you’ll inject a “time” dependency in BlabberModel so that time goes a little faster when you’re running your tests. Namely, you will use a mock alternative of Task.sleep so that Blabber.countdown(to:) doesn’t need to spend so much time waiting.

Open BlabberModel.swift and add a new property, where you’ll store the sleeping function that the model should use:

static var sleep: (UInt64) async throws -> Void = Task.sleep(nanoseconds:)

In the code above, you define a new property called sleep and set its default value to Task.sleep(nanoseconds:). Next, scroll to countdown(to:) and insert the following at the top:

let sleep = self.sleep

You can use the local copy of the function to do the “sleeping” you’ll need a few lines later.

Now, replace the try await Task.sleep(nanoseconds: 1_000_000_000) line with:

try await sleep(1_000_000_000)

Now, your model behaves exactly the same way as before by default. But you can easily override the sleep property in your tests to change the speed at which the code sleeps.

Updating the tests

To wrap up, you’ll update the tests next. Open BlabberTests.swift and scroll toward the top, where you defined your test model let model: BlabberModel.

After the line where you inject the test URL session, model.urlSession, append this line:

model.sleep = { try await Task.sleep(nanoseconds: $0 / 1_000_000_000) }

Your test implementation of sleep takes the parameter passed to the function, divides it by a billion and calls Task.sleep(nanoseconds:) with the result. Effectively, you still implement the same workflow as before and provide the same suspension point at the right moment in the execution. The only difference is that you run the code a billion times faster.

Run the tests one more time by pressing Command-U and check the duration. Injecting the test sleep function reduces the duration from about 5.5 seconds to around 0.03 seconds on my machine.

Now, you can keep growing your test suite without worrying about how much time it’s going to take to run all the tests!

With async/await and the modern concurrency APIs, designing asynchronous tests becomes much easier. Nevertheless, the design of your tests depends mostly on the code under test. Since your code will vary in nature, you’ll always need to create some slightly different setups and conduct tests somewhat differently.

In this chapter, you covered different situations and worked on building your own testing infrastructure. You’re now ready to write asynchronous tests in your own apps.

Key points

  • Annotate your test method with async to enable testing asynchronous code.
  • Use await with asynchronous functions to verify their output or side effects after they resume.
  • Use either mock types for your dependencies or the real type, if you can configure it for testing.
  • To test time-sensitive asynchronous code, run concurrent tasks to both trigger the code under test and observe its output or side effects.
  • await can suspend indefinitely. So, when testing, it’s a good idea to set a timeout for the tested asynchronous APIs whenever possible.