Testing TaskGroup Operations

TaskGroup lets you run multiple async operations concurrently. But testing concurrent code reveals a hidden problem in our mock.
Follow along with the code: iOS-Practice on GitHub
Code Under Test
func getWeatherForMultipleCities(_ cities: [String]) async -> [Result<Weather, Error>] {
await withTaskGroup(of: (String, Result<Weather, Error>).self) { group in
for city in cities {
group.addTask {
do {
let weather = try await self.getWeather(for: city)
return (city, .success(weather))
} catch {
return (city, .failure(error))
}
}
}
var results: [String: Result<Weather, Error>] = [:]
for await (city, result) in group {
results[city] = result
}
// Return in original order
return cities.map { results[$0]! }
}
}
The Tests That Crash
These tests look correct, but run them a few times and you'll see crashes:
func test_getWeatherForMultipleCities_fetchesAllConcurrently() async {
// Act
let results = await sut.getWeatherForMultipleCities(["A", "B", "C"])
// Assert
XCTAssertEqual(results.count, 3)
XCTAssertEqual(mockService.fetchCurrentWeatherCallCount, 3)
}
func test_getWeatherForMultipleCities_executesConcurrently() async {
// Arrange - Each fetch takes 100ms
mockService.fetchDelay = 0.1
let cities = ["A", "B", "C", "D", "E"]
// Act
let start = Date()
_ = await sut.getWeatherForMultipleCities(cities)
let elapsed = Date().timeIntervalSince(start)
// Assert - Should complete in ~100ms, not 500ms (sequential)
XCTAssertLessThan(elapsed, 0.3)
}
You might see errors like:
malloc: pointer being freed was not allocatedNSInvalidArgumentExceptionwith strange selectors- Random EXC_BAD_ACCESS crashes
The error messages are misleading. The real problem is a data race.
The Problematic Mock
Look at our MockWeatherService from the previous article:
class MockWeatherService: WeatherService {
private(set) var fetchCurrentWeatherCallCount = 0 // Problem!
private(set) var requestedCities: [String] = [] // Problem!
func fetchCurrentWeather(for city: String) async throws -> Weather {
fetchCurrentWeatherCallCount += 1 // Multiple tasks write here simultaneously
requestedCities.append(city) // Multiple tasks write here simultaneously
// ...
}
}
When TaskGroup runs 5 concurrent tasks, they all call fetchCurrentWeather at the same time. Multiple threads incrementing fetchCurrentWeatherCallCount and appending to requestedCities simultaneously causes memory corruption.
Why It Worked Before
In our previous async/await tests, each test made one call at a time:
_ = try await sut.getWeather(for: "London") // Single call, no concurrency
No concurrent access means no data race. The bug was always there—TaskGroup just exposed it.
The Fix: Actors
Swift actors serialize access to their state. We can use an internal actor to protect the mock's counters:
class MockWeatherService: WeatherService {
private actor State {
var callCount = 0
var cities: [String] = []
func recordCall(city: String) {
callCount += 1
cities.append(city)
}
}
private let state = State()
var fetchCurrentWeatherCallCount: Int {
get async { await state.callCount }
}
var requestedCities: [String] {
get async { await state.cities }
}
func fetchCurrentWeather(for city: String) async throws -> Weather {
await state.recordCall(city: city)
// ...
}
}
Now the tests need to await when reading the counters:
func test_getWeatherForMultipleCities_fetchesAllConcurrently() async {
let results = await sut.getWeatherForMultipleCities(["A", "B", "C"])
let callCount = await mockService.fetchCurrentWeatherCallCount // await!
XCTAssertEqual(results.count, 3)
XCTAssertEqual(callCount, 3)
}
Don't Forget the Production Code
The mock isn't the only problem. WeatherManager itself has a data race in its cache:
class WeatherManager {
private var cache: [String: Weather] = [:] // Accessed by multiple tasks!
func getWeather(for city: String) async throws -> Weather {
if let cached = cache[city] { return cached } // Read
let weather = try await service.fetchCurrentWeather(for: city)
cache[city] = weather // Write - race condition!
return weather
}
}
When TaskGroup spawns 5 concurrent calls to getWeather, they all read and write cache simultaneously.
For the production code, DispatchQueue.sync is a good fit since we want synchronous cache access:
class WeatherManager {
private var cache: [String: Weather] = [:]
private let queue = DispatchQueue(label: "weather.manager.cache")
func getWeather(for city: String) async throws -> Weather {
var cached: Weather?
queue.sync { cached = cache[city] }
if let cached { return cached }
let weather = try await service.fetchCurrentWeather(for: city)
queue.sync { cache[city] = weather }
return weather
}
}
Why DispatchQueue here instead of an actor? The cache access is a quick dictionary lookup—we don't want to suspend and yield to other tasks. queue.sync blocks briefly but keeps the code synchronous, which is fine for fast operations.
Testing Result Order
Even though tasks complete in arbitrary order, our code preserves input order:
func test_getWeatherForMultipleCities_returnsInOriginalOrder() async {
let cities = ["London", "Paris", "Tokyo"]
let results = await sut.getWeatherForMultipleCities(cities)
for (index, result) in results.enumerated() {
if case .success(let weather) = result {
XCTAssertEqual(weather.city, cities[index])
}
}
}
Testing Partial Failures
TaskGroup handles errors per-task without failing the whole group:
func test_getWeatherForMultipleCities_handlesPartialFailures() async {
let failingMock = SelectiveFailureMock()
failingMock.failingCities = ["Paris"]
let sut = WeatherManager(service: failingMock)
let results = await sut.getWeatherForMultipleCities(["London", "Paris", "Tokyo"])
XCTAssertEqual(results.count, 3)
if case .success = results[0] { } else { XCTFail("London should succeed") }
if case .failure = results[1] { } else { XCTFail("Paris should fail") }
if case .success = results[2] { } else { XCTFail("Tokyo should succeed") }
}
Key Takeaways
| Issue | Solution |
|---|---|
| Data race in mock counters | Use internal actor for thread-safe state |
| Data race in production cache | Use DispatchQueue.sync for quick synchronous access |
| Misleading crash messages | Recognize malloc errors as data race symptoms |
| Tests pass sometimes | Data races are non-deterministic—run tests multiple times |
| Mock worked in simple tests | Concurrency exposes bugs that sequential code hides |
Testing concurrent code requires thread-safe mocks and thread-safe production code. When you see random crashes in TaskGroup tests, check both.
