Swift
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Optionals
-
Section 4: Object-Oriented and Value Types
-
Section 5: Memory Management
-
Section 6: Generics and Error Handling
-
Section 7: Concurrency
-
Section 8: Working with Collections
-
Section 9: Codable and Data Handling
-
Section 10: Protocol-Oriented Programming
-
Section 11: Testing and Tooling
-
Section 12: Practical Projects
-
Section 13: Interview Practice
-
Section 14: More Practice Exercises
-
Section 15: More Standard Library
-
Section 16: Advanced Concurrency
-
Section 17: Foundation Framework Deep Dive
-
Section 18: URLSession and Networking Deep Dive
-
Section 19: Combine Framework
-
Section 20: SwiftUI Fundamentals for Swift Developers
-
Section 21: Server-Side Swift with Vapor
-
Section 22: Swift Package Manager Deep Dive
-
Section 23: Swift Concurrency Deep Dive
-
Section 24: More Language Features
-
Section 25: Error Handling Deep Dive
-
Section 26: Testing Deep Dive
-
Section 27: Data Structures and Algorithms in Swift
-
Section 28: More Practice Exercises
-
Section 29: More Interview Practice
-
Section 30: Swift Macros (Swift 5.9+)
-
Section 31: Property Wrappers Ecosystem
-
Section 32: Swift Interop Deep Dive
-
Section 33: iOS App Architecture Patterns
-
Section 34: Performance and Debugging
-
Section 35: App Distribution and CI/CD
-
Section 36: More Practical Projects
-
Section 37: SwiftData and Persistence
-
Section 38: More Design Patterns
-
Section 39: More Review and Practice
-
Section 40: More Foundation Deep Dive
-
Section 41: Advanced Collections in Swift
-
Section 42: Advanced Generics Practice
-
Section 43: UIKit for Legacy and Hybrid Apps
-
Section 44: watchOS and visionOS Development Basics
-
Section 45: More Networking Patterns
-
Section 46: More Testing Practice
-
Section 47: Accessibility in Swift Apps
-
Section 48: Localization
-
Section 49: More Practical Projects Round 2
-
Section 50: Swift Charts Framework
-
Section 51: More Interview and Algorithm Practice
-
Section 52: Final Practice and Mastery
-
Section 53: Swift Compiler and Build System
-
Section 54: More Concurrency Practice
-
Section 55: App Store Guidelines and Review
-
Section 56: More Design and Architecture
118: Async Testing Patterns
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:
- Correctly waits for the async operations to complete (removing the false positive).
- Is marked to handle potential errors if the cache throws.
- Ensures the test doesn't hang indefinitely by implementing a timeout pattern (e.g., 3 seconds) using a
TaskGroupor a similar async coordination pattern.
There are no comments for now.