Modern Concurrency: Beyond the Basics

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

Part 1: AsyncStream & Continuations

09. Unit Testing Tools

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: 08. Wrapping Callback With Continuation Next episode: 10. Conclusion

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: 09. Unit Testing Tools

In episode 6, when you wrote unit tests, there were two issues: await doesn’t time out, and the tests take more than 5 seconds to run, because of the 1-second waits built into the countdown.

In this episode, you’ll create unit testing tools to fix these issues: You’ll create a timeout mechanism, and you’ll speed up execution with a custom sleep function.

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.

Build and run the app. This is just to make sure you don’t have to wait for a simulator to start up when you run your tests.

Adding TimeoutTask for safer testing

You can’t let your tests hang indefinitely, so you’ll create a new type called TimeoutTask. It’s like Task except it throws an error if the asynchronous code doesn’t complete in time.

In the BlabberTests/Utility group, create a new Swift file called TimeoutTask.swift. Check its Target Membership to make sure it only belongs to BlabberTests, not to Blabber. Now, create a class:

class TimeoutTask<Success> {

}

Like Task, TimeoutTask returns a Success type. If the task doesn’t return a result, Success is Void.

Extend TimeoutTask with an error to throw if the task times out:

extension TimeoutTask {
  struct TimeoutError: LocalizedError {

  }
}

Add its description:

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

Up in the TimeoutTask class, add a property:

let nanoseconds: UInt64

This is the maximum duration you allow for the task. One more property:

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

This is an async throwing closure that conforms to the Sendable protocol. A Sendable closure is thread-safe: It’s safe to transfer it between concurrency domains. You’ll learn more about Sendable when you learn about actors in Part 2 of this course.

Xcode wants an initializer, so start writing one:

init(
  seconds: TimeInterval, 
  
) {

}

You accept the maximum duration in seconds: You’ll convert this value to nanoseconds to store in the nanoseconds property. Now the second parameter:

init(
  seconds: TimeInterval, 
  🟩
  operation: @escaping @Sendable () async throws -> Success
  🟥
) {  
 
}

@escaping means you may store and execute the closure outside of the initializer’s scope.

And in the body:

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

Now, add another property to TimeoutTask: a checked continuation to let you suspend and resume execution:

private var continuation: CheckedContinuation<Success, Error>?

continuation is an optional so, after you use it, you’ll be able to destroy it by setting it to nil.

And a value property to start the work and asynchronously return the result of the task:

var value: Success {
  get async throws {

  }
}

You declare its getter as async and throws so you can asynchronously control the timing of the execution for your tests.

In the getter, create a checked throwing continuation:

private var continuation: CheckedContinuation<Success, Error>?

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

This lets you either complete successfully or throw an error if the operation times out.

Inside the continuation closure, add a Task to sleep for the maximum duration:

var value: Success {
  get async throws {
    try await withCheckedThrowingContinuation { continuation in
      self.continuation = continuation
      🟩
      Task {
        try await Task.sleep(nanoseconds: nanoseconds)

      }    
      🟥
    }
  }
}

If the sleep task completes, use the continuation to throw a TimeoutError:

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  // then destroy the continuation
        🟥
      }    
    }
  }
}

This takes care of the part of the code that times out.

Now, add the actual Task to perform the operation:

Task {
  try await Task.sleep(nanoseconds: nanoseconds)
  self.continuation?.resume(throwing: TimeoutError())
  self.continuation = nil
}
🟩
Task {
  let result = try await operation()  // execute the operaton passed into the initializer
  self.continuation?.resume(returning: result)  // return the result
  self.continuation = nil  // and destroy the continuation
}
🟥

You’re starting two asynchronous tasks in parallel. Whichever task completes first gets to use the continuation, while the slower task gets canceled.

It’s just possible that both tasks might try to use continuation at precisely the same time — leading to a crash. You’ll learn about writing safe concurrent code in Part 2. For now, leave this TimeoutTask code as it is.

Canceling your task

For completeness, add one more method to TimeoutTask:

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

You make sure the continuation finishes, no matter what happens.

Using TimeoutTask

So how to use your new TimeoutTask in your unit test? In BlabberTests, look at testModelCountdown().

This code could hang at prefix(4) if it doesn’t receive 4 requests.

Wrap this code in a TimeoutTask:

}
.value
  • TestURLProtocol.requests is the operation closure of TimeoutTask.
  • value starts this task and returns its result.

Task.value waits for the task to complete, then returns its value, similar to a promise in other languages.

Now that it’s inside the TimeoutTask closure, you need to await TestURLProtocol.requests: Accept Xcode’s fix.

Now, check your simulator setting and run the test.

Success! Just like you got in episode 6. So TimeoutTask didn’t break anything. You’re probably itching to see if the timeout really works, but you’ll do this after you speed up the test, so you won’t have to wait so long for it to time out.

Having to wait 5 seconds for a successful test is pretty tedious. It’s enough to make you reluctant to run unit tests at all. So your next job is to create a tool to speed them up.

Speeding up asynchronous tests

You’ll use a mock Task.sleep to inject a time dependency so you can set time to go faster in your tests.

In BlabberModel, add a sleep property:

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

You’ll use this property to store the sleeping function you want the model to use. When you’re not running unit tests, this is just the old nanosecond-based Task.sleep. For speeding up unit tests, this version of sleep is more convenient: You can easily speed up your tests by a factor of a billion!

Now, add this property to the countdown method:

let sleep = self.sleep

You’re about to substitute the mock sleep function for Task.sleep… in the AsyncStream closure:

try await sleep(1_000_000_000)

Now, by default, BlabberModel behaves exactly the same way as before. But you can override sleep in your tests, to make them run faster.

Updating the tests

In BlabberTests, add this line to the end of your model definition (before return model):

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.

Press Command-U to run both tests.

Much faster! From over 5 seconds to less than 0.02 seconds!

Testing TimeoutTask

Now, to see if TimeoutTask really times out:

async let messages = TimeoutTask(seconds: 🟩1🟥) {
  await TestURLProtocol.requests
    .prefix(🟩5🟥)

Reduce wait time to 1 second. Increase expected number of requests to 5 and press Command-U

And it works, your test timed out after 1 second!

Test Suite 'All tests' started at 2021-12-29 11:58:02.211
Test Suite 'BlabberTests.xctest' started at 2021-12-29 11:58:02.212
Test Suite 'BlabberTests' started at 2021-12-29 11:58:02.212
Test Case '-[BlabberTests.BlabberTests testModelCountdown]' started.
<unknown>:0: error: -[BlabberTests.BlabberTests testModelCountdown] : failed: caught error: "The operation timed out."
Test Case '-[BlabberTests.BlabberTests testModelCountdown]' failed (1.072 seconds).
Test Case '-[BlabberTests.BlabberTests testModelSay]' started.
Test Case '-[BlabberTests.BlabberTests testModelSay]' passed (0.003 seconds).
Test Suite 'BlabberTests' failed at 2021-12-29 11:58:03.287.
	 Executed 2 tests, with 1 failure (1 unexpected) in 1.075 (1.076) seconds
Test Suite 'BlabberTests.xctest' failed at 2021-12-29 11:58:03.288.
	 Executed 2 tests, with 1 failure (1 unexpected) in 1.075 (1.076) seconds
Test Suite 'All tests' failed at 2021-12-29 11:58:03.288.
	 Executed 2 tests, with 1 failure (1 unexpected) in 1.075 (1.077) seconds

Change prefix back to 4 and run the test again.

Test Suite 'Selected tests' started at 2021-12-29 12:04:16.651
Test Suite 'BlabberTests.xctest' started at 2021-12-29 12:04:16.652
Test Suite 'BlabberTests' started at 2021-12-29 12:04:16.652
Test Case '-[BlabberTests.BlabberTests testModelCountdown]' started.
Test Case '-[BlabberTests.BlabberTests testModelCountdown]' passed (0.012 seconds).
Test Suite 'BlabberTests' passed at 2021-12-29 12:04:16.664.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.012 (0.013) seconds
Test Suite 'BlabberTests.xctest' passed at 2021-12-29 12:04:16.665.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.012 (0.013) seconds
Test Suite 'Selected tests' passed at 2021-12-29 12:04:16.665.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.012 (0.014) seconds

Your tests succeed. Set wait time back to 10:

async let messages = TimeoutTask(seconds: 🟩10🟥) {

And there you have some handy tools for testing asynchronous code. It’s time to wrap up this part of the course before moving on to TaskGroups and Actors.