Testing Mastery

Testing TaskGroup Operations

April 25, 2026
5 min read
Featured image for blog post: 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 allocated
  • NSInvalidArgumentException with strange selectors
  • Random EXC_BAD_ACCESS crashes

Data race crash in Xcode

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

IssueSolution
Data race in mock countersUse internal actor for thread-safe state
Data race in production cacheUse DispatchQueue.sync for quick synchronous access
Misleading crash messagesRecognize malloc errors as data race symptoms
Tests pass sometimesData races are non-deterministic—run tests multiple times
Mock worked in simple testsConcurrency 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch