Testing completion handler based code in Swift Testing

Published on: December 4, 2024

Swift's new modern testing framework is entirely driven by asynchronous code. This means that all of our test functions are async and that we have to make sure that we perform all of our assertions “synchronously”.

This also means that completion handler-based code is not as straightforward to test as code that leverages structured concurrency.

In this post, we’ll explore two approaches that can be useful when you’re testing code that uses callbacks or completion handlers in Swift Testing.

First, we’ll look at the built-in confirmation method from the Swift Testing framework and why it might not be what you need. After that, we’ll look at leveraging continuations in your unit tests to test completion handler based code.

Testing async code with Swift Testing’s confirmations

I will start this section by stating that the main reason that I’m covering confirmation is that it’s present in the framework, and Apple suggests it as an option for testing async code. As you’ll learn in this section, confirmation is an API that’s mostly useful in specific scenarios that, in my experience, don’t happen all that often.

With that said, let’s see what confirmation can do for us.

Sometimes you'll write code that runs asynchronously and produces events over time.

For example, you might have a bit of code that performs work in various steps, and during that work, certain progress events should be sent down an AsyncStream.

As usual with unit testing, we're not going to really care about the exact details of our event delivery mechanism.

In fact, I'm going to show you how this is done with a closure instead of an async for loop. In the end, the details here do not matter. The main thing that we're interested in right now is that we have a process that runs and this process has some mechanism to inform us of events while this process is happening.

Here are some of the rules that we want to test:

  • Our object has an async method called createFile that kicks of a process that involves several steps. Once this method completes, the process is finished too.
  • The object also has a property onStepCompleted that we can assign a closure to. This closure is called for every completed step of our process.

The onStepCompleted closure will receive one argument; the completed step. This will be a value of type FileCreationStep:

enum FileCreationStep {
  case fileRegistered, uploadStarted, uploadCompleted
}

Without confirmation, we can write our unit test for this as follows:

@Test("File creation should go through all three steps before completing")
func fileCreation() async throws {
  var completedSteps: [FileCreationStep] = []
  let manager = RemoteFileManager(onStepCompleted: { step in
    completedSteps.append(step)
  })

  try await manager.createFile()
  #expect(completedSteps == [.fileRegistered, .uploadStarted, .uploadCompleted])
}

We can also refactor this code and leverage Apple’s confirmation approach to make our test look as follows:

@Test("File creation should go through all three steps before completing")
func fileCreation() async throws {
  try await confirmation(expectedCount: 3) { confirm in 
    var expectedSteps: [FileCreationStep] = [.fileRegistered, .uploadStarted, .uploadCompleted]

    let manager = RemoteFileManager(onStepCompleted: { step in
      #expect(expectedSteps.removeFirst() == step)
      confirm()
    })

    try await manager.createFile()
  }
}

As I’ve said in the introduction of this section; confirmation's benefits are not clear to me. But let’s go over what this code does…

We call confirmation and we provide an expected number of times we want a confirmation event to occur.

Note that we call the confirmation with try await.

This means that our test will not complete until the call to our confirmation completes.

We also pass a closure to our confirmation call. This closure receives a confirm object that we can call for every event that we receive to signal an event has occurred.

At the end of my confirmation closure I call try await manager.createFile(). This kicks off the process and in my onStepCompleted closure I verify that I’ve received the right step, and I signal that we’ve received our event by calling confirm.

Here’s what’s interesting about confirmation though…

We must call the confirm object the expected number of times before our closure returns.

This means that it’s not usable when you want to test code that’s fully completion handler based since that would mean that the closure returns before you can call your confirmation the expected number of times.

Here’s an example:

@Test("File creation should go through all three steps before completing")
func fileCreationCompletionHandler() async throws {
  await confirmation { confirm in 
    let expectedSteps: [FileCreationStep] = [.fileRegistered, .uploadStarted, .uploadCompleted]
    var receivedSteps: [FileCreationStep] = []

    let manager = RemoteFileManager(onStepCompleted: { step in
      receivedSteps.append(step)
    })

    manager.createFile {
      #expect(receivedSteps == expectedSteps)
      confirm()
    }
  }
}

Notice that I’m still awaiting my call to confirmation. Instead of 3 I pass no expected count. This means that our confirm should only be called once.

Inside of the closure, I’m running my completion handler based call to createFile and in its completion handler I check that we’ve received all expected steps and then I call confirm() to signal that we’ve performed our completion handler based work.

Sadly, this test will not work.

The closure returns before the completion handler that I’ve passed to createFile has been called. This means that we don’t call confirm before the confirmation’s closure returns, and that results in a failing test.

So, let’s take a look at how we can change this so that we can test our completion handler based code in Swift Testing.

Testing completion handlers with continuations

Swift concurrency comes with a feature called continuations. If you are not familiar with them, I'd highly recommend that you read my post where I go into how you can use continuations. For the remainder of this section, I'm going to assume that you know continuations basics. I will just look at how they work in the context of Swift testing.

The problem that we're trying to solve is essentially that we do not want our test function to return until our completion handler based code has fully executed. In the previous section, we saw how using a confirmation doesn't quite work because the confirmation closure returns before the file managers create file finishes its work and calls its completion handler.

Instead of a confirmation, we can have our test wait for a continuation. Inside of the continuation, we can call our completion handler based APIs and then resume the continuation when our callback is called and we know that we've done all the work that we need to do. Let's see what that looks like in a test.

@Test("File creation should go through all three steps before completing")
func fileCreationCompletionHandler() async throws {
  await withCheckedContinuation { continuation in
    let expectedSteps: [FileCreationStep] = [.fileRegistered, .uploadStarted, .uploadCompleted]
    var receivedSteps: [FileCreationStep] = []

    let manager = RemoteFileManager(onStepCompleted: { step in
      receivedSteps.append(step)
    })

    manager.createFile {
      #expect(receivedSteps == expectedSteps)
      continuation.resume(returning: ())
    }
  }
}

This test looks very similar to the test that you saw before, but instead of waiting for a confirmation, we're now calling the withCheckedContinuation function. Inside of the closure that we passed to that function, we perform the exact same work that we performed before.

However, in the createFile function’s completion handler, we resume the continuation only after we've made sure that the received steps from our onStepCompleted closure match with the steps to be expected.

So we're still testing the exact same thing, but this time our test is actually going to work. That's because the continuation will suspend our test until we resume the continuation.

When you're testing completion handler based code, I usually find that I will reach for this instead of reaching for a confirmation because a confirmation does not work for code that does not have something to await.

In Summary

In this post, we explored the differences between continuations and confirmations for testing asynchronous code.

You've learned that Apple's recommended approach for testing closure based asynchronous code is with confirmations. However, in this post, we saw that we have to call our confirm object before the confirmation closure returns, so that means that we need to have something asynchronous that we await for, which isn't always the case.

Then I showed you that if you want to test a more traditional completion handler based API, which is probably what you're going to be doing, you should be using continuations because these allow our tests to suspend.

We can resume a continuation when the asynchronous work that we were waiting for is completed and we’ve asserted the results of our asynchronous work are what we’d like them to be using the #expect or #require macros.

Categories

Testing

Subscribe to my newsletter