Modern Concurrency: Beyond the Basics

Oct 20 2022 · Swift 5.5, iOS 15, Xcode 13.4

Part 1: AsyncStream & Continuations

06. Unit Testing

Episode complete

Play next episode

Next
About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 05. Using a Buffered AsyncStream Next episode: 07. Wrapping Delegate With Continuation

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Transcript: 06. Unit Testing

Refresh your browser to make sure the course server is running or restart the server in Terminal. Continue with your project from the previous episode or open the starter project for this episode.

Capturing network calls under test

In this episode, you’ll add tests for the BlabberModel type. To verify that BlabberModel sends correct data to the server, you’ll configure a custom URLSession for your tests to work with. You’ll intercept and record all network requests using a custom URLProtocol subclass.

Implementing a custom URLProtocol

In the BlabberTests Utility group, open TestURLProtocol.swift:

The minimum protocol requirements are already included in the code:

  • canInit 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 can alter requests on the fly. In this case, you simply return the given request with no changes.
  • startLoading() loads the request and sends a response back to the client.
  • You call stopLoading() 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.

startLoading()

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.

To get started, add a 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.

This property is static. Because of the way you pass these URL protocols to URLSessionConfiguration, you can’t easily access instance properties. For the simple tests in this course, this works fine.

Next, add some code at the bottom of startLoading(). First, check that the request has a non-nil httpBodyStream input stream:

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

This is the stream you use to read the request data.

Next, make a new mutable request variable so you can modify the request before storing it:

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

Then read the request contents from httpBodyStream and store the data in httpBody:

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

var request = request
🟩
request.httpBody = stream.data

Finally, save the request in lastRequest:

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

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

Now your tests can verify the contents after the network call completes. You’re all set to use TestURLProtocol to test BlabberModel.

Creating a model for testing

In BlabberTests, create a model property …

let model: BlabberModel

… with a closure to initialize it:

let model: BlabberModel🟩 = {  

}()

First, create a new BlabberModel with username test

let model: BlabberModel = {
  🟩
  // First, create a new `BlabberModel` with username test
  let model = BlabberModel()
  model.username = "test"

  // Then create a URL session configuration that uses `TestURLProtocol`
  let testConfiguration = URLSessionConfiguration.default
  testConfiguration.protocolClasses = [TestURLProtocol.self]

  // And tell the model to use this new session
  model.urlSession = URLSession(configuration: testConfiguration)
  // And return the model
  return model
  🟥
}()

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

Adding a simple asynchronous test

And finally, write your first test!

func testModelSay() async throws {  // say first paragraph below
  try await model.say("Hello!")  

}

When creating asynchronous tests, remember to add the async keyword to each test method. Doing this lets you await your code under test and easily verify the output.

model is already configured to use the test-suitable URL session, so you don’t need to do any additional setup — just call model.say right away. Next, add a test expectation:

// first, unwrap the optional TestURLProtocol.lastRequest
let request = try XCTUnwrap(TestURLProtocol.lastRequest)

// then check the URL matches the expected address
XCTAssertEqual(
  request.url?.absoluteString, "http://localhost:8080/chat/say"
)

You verify that the last request the network performed — this model.say("Hello!") — was sent to the correct URL.

If the model sends the data to the correct endpoint, check that it also sends the correct data:

// first, unwrap the request body
let httpBody = try XCTUnwrap(request.httpBody)
// then decode the request body: it should decode as a Message
let message = try XCTUnwrap(try? JSONDecoder()
  .decode(Message.self, from: httpBody))
// and the decoded Message should be "Hello!"
XCTAssertEqual(message.message, "Hello!")

If you’ve written asynchronous tests before, you’ll appreciate how short and clear this test code is. If you haven’t written asynchronous tests before, you really don’t need to know how much effort you used to need, to set up a good asynchronous test!

Check the simulator device is one that’s already started up.

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

Testing values over time with AsyncStream

That was easy. Now, how to test asynchronous work that may yield many values, like countdown? This sequence requires up to 4 network requests. To guarantee the method works correctly, you must verify more than the last value.

Add some properties to TestURLProtocol

// add a static property holding a continuation
static private var continuation: AsyncStream<URLRequest>.Continuation?

// add a static property that returns an asynchronous stream that emits requests
static var requests: AsyncStream<URLRequest> = {
  AsyncStream { continuation in
  // store the AsyncStream's continuation so you can 
  // emit a value each time `TestURLProtocol` responds to a request
    TestURLProtocol.continuation = continuation
  }
}()

continuation is a static property, so you can have only one active instance of requests at a time.

To emit a value, add a didSet handler to lastRequest:

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.

Back in BlabberTests, add another test:

func testModelCountdown() async throws {
  // call countdown
  try await model.countdown(to: "Tada!")
  // iterate over the stream of requests to print the recorded values
  for await request in TestURLProtocol.requests {
    print(request)
  }
}

First, call countdown. And iterate over the stream of requests to print the recorded values.

It hangs!

Stop execution, then set breakpoints on all 3 lines and run the test again. nThe debugger stops at the first breakpoint: Click Continue program execution

It stops at the second breakpoint: Click Continue program execution. But you never reach the print statement.

BTW notice await doesn’t time out. You’ll fix that in a later episode. Stop execution and disable the breakpoints. So what’s the problem here?

In TestURLProtocol, look at how you emit the requests: You only emit values when lastRequest is set.

Back in BlabberTests, by the time the for await loop starts, countdown has already finished, so there aren’t any requests to read.

You need both tasks to run at the same time, and you remember how to use async let to do this.

In testModelCountdown(), instead of try await model.countdown, give the call a name so you can await it later:

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

Because countdown doesn’t return a value, you specify its binding type as Void, to avoid a warning.

Also give a name to requests:

async let countdown: Void = model.countdown(to: "Tada!")
🟩async let messages = 🟥TestURLProtocol.requests // and delete the for await closure

The closure was just to print the requests, to demonstrate it was hanging. And now wait for them:

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

You only care about the messages. Then check the results:

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

The error message is because you need to process requests to get it to look like this.

Add this modifier to TestURLProtocol.requests:

.prefix(4)

You only need as many requests as you expect during a successful run of countdown:

The 4 requests that produce these 4 messages.

You need to extract the messages from the requests. Start with the HTTP body of each request:

.compactMap(\.httpBody)

Then decode the body as a Message:

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

And return its message property:

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

Finally, collect these messages into an array:

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

reduce(...) runs this closure for each element in the sequence and adds each request to result. Now, you can process the elements as a simple plain array. Run the test.

After the countdown finishes, your test succeeds! Great work! But there are still a couple of issues.

The execution time is more than 5 seconds because the app waits 1 second for each request, so your test has to wait too. It would be good to be able to speed up execution for your tests.

Also, the code will hang if you only get three requests instead of the expected four. The execution will stop at prefix(4) and wait for a fourth element. Test this by asking for five results.

You really need some kind of timeout mechanism. Change back to 4 results and stop execution.

You’ll take care of these issues after the next two episodes, where you’ll learn about manual continuations.