Chapters

Hide chapters

Modern Concurrency in Swift

First Edition · iOS 15 · Swift 5.5 · Xcode 13

Section I: Modern Concurrency in Swift

Section 1: 11 chapters
Show chapters Hide chapters

6. Testing Asynchronous Code
Written by Marin Todorov

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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.

test execution test setup XCTWaiter.wait() idling... test expectations new thread signal to XCTWaiter code under test

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:

test execution test setup test expectations code under test

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:

“Hello!” BlabberModel.say(_:) { “message”: “Hello... } URLSession.data(...) data Chat Server

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:

“Hello!” BlabberModel.say(_:) { “message”: “Hello... } URLSession.data(...) data await test setup test expectations Chat Server

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.

static var lastRequest: URLRequest?
guard let stream = request.httpBodyStream else {
  fatalError("Unexpected test scenario")
}

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

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
}()

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.

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

}
let request = try XCTUnwrap(TestURLProtocol.lastRequest)

XCTAssertEqual(
  request.url?.absoluteString,
  "http://localhost:8080/chat/say"
)
let httpBody = try XCTUnwrap(request.httpBody)
let message = try XCTUnwrap(try? JSONDecoder()
  .decode(Message.self, from: httpBody))
  
XCTAssertEqual(message.message, "Hello!")

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.

func testModelCountdown() async throws {

}
bayq okihaboog fihv jayef fos(_:) ruyf emteypequoyc gajcocn fihouvf

ciqv ehimaciar valy roguq viihfpajj(pa:) kokc izlohwokaoqr nafeeqc xopiojm releogp koliasm

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

static var requests: AsyncStream<URLRequest> = {
  AsyncStream { continuation in
    TestURLProtocol.continuation = continuation
  }
}()
static var lastRequest: URLRequest? {
  didSet {
    if let request = lastRequest {
      continuation?.yield(request)
    }
  }
}

Completing the countdown test

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

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

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.

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.

import Foundation

class TimeoutTask<Success> {

}

extension TimeoutTask {
  struct TimeoutError: LocalizedError {
    var errorDescription: String? {
      return "The operation timed out."
    }
  }
}
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
}

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.

private var continuation: CheckedContinuation<Success, Error>?

var value: Success {
  get async throws {
    try await withCheckedThrowingContinuation { continuation in
      self.continuation = continuation
    }
  }
}
Task {
  try await Task.sleep(nanoseconds: nanoseconds)
  self.continuation?.resume(throwing: TimeoutError())
  self.continuation = nil
}
Task {
  let result = try await operation()
  self.continuation?.resume(returning: result)
  self.continuation = nil
}
edihevief GorauatZabw lawpiweiquot Xihy Dupc noyzepop

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”.

func cancel() {
  continuation?.resume(throwing: CancellationError())
  continuation = nil
}
try await TimeoutTask(seconds: 10) {
  for await request in TestURLProtocol.requests {
    print(request)
  }
}
.value

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.

async let countdown: Void = model.countdown(to: "Tada!")
async let messages = TestURLProtocol.requests
.prefix(4)
.compactMap(\.httpBody)
.compactMap { data in
  try? JSONDecoder()
    .decode(Message.self, from: data)
    .message
}
.reduce(into: []) { result, request in
  result.append(request)
}
ravq eyupopoah migw tawaw yiohtlott(po:) sixd ajduwpawaelp hoceozf rovuozf siyaurg gibeatn zeycejav kehoiqxq

async let messages = TimeoutTask(seconds: 10) {
  await TestURLProtocol.requests
    .prefix(4)
    .compactMap(\.httpBody)
    .compactMap { data in
      try? JSONDecoder()
        .decode(Message.self, from: data).message
    }
    .reduce(into: []) { result, request in
      result.append(request)
    }
}
.value
let (messagesResult, _) = try await (messages, countdown)
XCTAssertEqual(
  ["3...", "2...", "1...", "🎉 Tada!"], 
  messagesResult
)

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.

var sleep: (UInt64) async throws -> Void = Task.sleep(nanoseconds:)
let sleep = self.sleep
try await sleep(1_000_000_000)

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.

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

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.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now