Skip to Content
Course content

118: Async Testing Patterns

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember the first time I tried testing an async function in Swift. I thought, "I'll just wrap the call in a Task block and put my assertions inside there." It seemed intuitive. I hit Cmd+U, the test turned green instantly, and I felt like a genius. Then I intentionally broke the production code—I changed the return value to be wrong—and the test still turned green. I had just spent twenty minutes writing a test that was literally incapable of failing.

The "False Positive" Trap

Let's look at why that happened. Suppose we have a simple WeatherService that fetches a temperature. I'll write a test the way I did it back then:

func testFetchTemperature_Naive() {
    let service = WeatherService()
    
    Task {
        let temp = try await service.fetchTemperature(for: "London")
        XCTAssertEqual(temp, 20)
    }
}

If you run this, XCTest starts the method, hits the Task block, schedules that work for later, and then immediately reaches the end of the function. As far as the test runner is concerned, the function finished without crashing or throwing an error. Success! Meanwhile, the actual assertion inside the Task might run milliseconds later, fail, and print a message to the console, but the test has already been marked as passed. This is a nightmare scenario in a CI/CD pipeline.

Letting the Test Wait

The fix is simpler than I initially thought: we make the test method itself async. Swift's testing framework is designed to handle this. If the test function is marked async throws, the runner will actually wait for the await calls to complete before deciding if the test passed.

I'll rewrite that same test, and this time, I'm not wrapping it in a Task:

func testFetchTemperature_Correct() async throws {
    let service = WeatherService()
    
    // The test runner now pauses here until fetchTemperature returns
    let temp = try await service.fetchTemperature(for: "London")
    
    XCTAssertEqual(temp, 20)
}

Now, when I break the code, the test actually fails. It's a small change, but it shifts the responsibility of lifecycle management from me to the XCTest runner. I'm no longer "firing and forgetting"; I'm orchestrating a sequence.

Dealing with the "Infinite Hang"

But here is where things get tricky. What happens if the WeatherService has a bug where it just... hangs? Maybe a network request never times out, or a deadlock occurs. If I use the async throws pattern above, my test suite will just stop. It'll sit there forever, and my build pipeline will time out after an hour.

I need a way to say, "Wait for this, but only for 2 seconds." Swift doesn't have a built-in await withTimeout yet, so I usually implement a helper pattern using a TaskGroup. I tried a few variations, and this is the one that feels the most robust:

func testFetchTemperature_WithTimeout() async throws {
    let service = WeatherService()
    
    try await withThrowingTaskGroup(of: Int.self) { group in
        // Start the actual work
        group.addTask {
            try await service.fetchTemperature(for: "London")
        }
        
        // Start a "timer" task that throws after a delay
        group.addTask {
            try await Task.sleep(nanoseconds: 2 * 1_000_000_000)
            throw TestError.timeout
        }
        
        // The first one to finish wins. 
        // If the timer wins, the test fails.
        let result = try await group.next()
        group.cancelAll() // Clean up the loser
        
        XCTAssertEqual(result, 20)
    }
}

I like this approach because it leverages structured concurrency. By calling group.cancelAll(), I ensure that if the weather service finally responds 10 seconds later, it doesn't keep eating resources in the background while I've already moved on to the next test. It turns a potential hang into a predictable failure.




📋 Practical Task

Fixing the Hanging Image Cache Test

You are reviewing a teammate's code for an ImageCache system. They wrote a test to ensure that an image is cached correctly after a simulated network download, but the test is currently a "false positive"—it passes even when the cache logic is broken because it uses an unstructured Task block.

The Scenario: The ImageCache.shared.store(image:for:) method is async. The test currently looks like this:

func testImageCaching() {
    let cache = ImageCache.shared
    let testImage = UIImage()

    Task {
        await cache.store(image: testImage, for: "profile_pic")
        let retrieved = await cache.retrieve(for: "profile_pic")
        XCTAssertNotNil(retrieved)
    }
}

Your Goal: Refactor this test so that it:

  1. Correctly waits for the async operations to complete (removing the false positive).
  2. Is marked to handle potential errors if the cache throws.
  3. Ensures the test doesn't hang indefinitely by implementing a timeout pattern (e.g., 3 seconds) using a TaskGroup or a similar async coordination pattern.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.