Testing Concurrent Code
Testing Concurrent Code
Section titled “Testing Concurrent Code”Use this when:
- You are writing async tests.
- A test is flaky because of task scheduling or actor isolation.
- You need to replace XCTest waiting APIs or verify deallocation.
Skip this file if:
- You mainly need production ownership guidance. Use
actors.md,tasks.md, ormemory-management.md.
Jump to:
- Swift Testing (Recommended)
- Awaiting Async Callbacks
- Setup and Teardown
- Handling Flaky Tests
- Swift Concurrency Extras
- XCTest Patterns (Legacy)
- Memory Management Tests
- Testing Checklist
Recommendation: Use Swift Testing
Section titled “Recommendation: Use Swift Testing”Swift Testing is strongly recommended for new projects and tests. It provides:
- Modern Swift syntax with macros
- Better concurrency support
- Cleaner test structure
- More flexible test organization
XCTest patterns are included for legacy codebases.
Swift Testing Basics
Section titled “Swift Testing Basics”Simple async test
Section titled “Simple async test”@Test@MainActorfunc emptyQuery() async { let searcher = ArticleSearcher() await searcher.search("") #expect(searcher.results == ArticleSearcher.allArticles)}Key differences from XCTest:
@Testmacro instead ofXCTestCase#expectinstead ofXCTAssert- Structs preferred over classes
- No
testprefix required
Testing with actors
Section titled “Testing with actors”@Test@MainActorfunc searchReturnsResults() async { let searcher = ArticleSearcher() await searcher.search("swift") #expect(!searcher.results.isEmpty)}Mark test with actor if system under test requires it.
Awaiting Async Callbacks
Section titled “Awaiting Async Callbacks”Using continuations
Section titled “Using continuations”When testing unstructured tasks:
@Test@MainActorfunc searchTaskCompletes() async { let searcher = ArticleSearcher()
await withCheckedContinuation { continuation in _ = withObservationTracking { searcher.results } onChange: { continuation.resume() }
searcher.startSearchTask("swift") }
#expect(searcher.results.count > 0)}Use when: Testing code that spawns unstructured tasks.
Using confirmations
Section titled “Using confirmations”For structured async code:
@Test@MainActorfunc searchTriggersObservation() async { let searcher = ArticleSearcher()
await confirmation { confirm in _ = withObservationTracking { searcher.results } onChange: { confirm() }
// Must await here for confirmation to work await searcher.search("swift") }
#expect(!searcher.results.isEmpty)}Critical: Must await async work for confirmation to validate.
Setup and Teardown
Section titled “Setup and Teardown”Using init/deinit
Section titled “Using init/deinit”@MainActorfinal class DatabaseTests { let database: Database
init() async throws { database = Database() await database.prepare() }
deinit { // Synchronous cleanup only }
@Test func insertsData() async throws { try await database.insert(item) #expect(await database.count() == 1) }}Limitation: deinit cannot call async methods.
Test Scoping Traits
Section titled “Test Scoping Traits”For async teardown:
@MainActorstruct DatabaseTrait: SuiteTrait, TestTrait, TestScoping { func provideScope( for test: Test, testCase: Test.Case?, performing function: () async throws -> Void ) async throws { let database = Database()
try await Environment.$database.withValue(database) { await database.prepare() try await function() await database.cleanup() // Async teardown } }}
// Environment for task-local storage@MainActorstruct Environment { @TaskLocal static var database = Database()}
// Apply to suite@Suite(DatabaseTrait())@MainActorfinal class DatabaseTests { @Test func insertsData() async throws { try await Environment.database.insert(item) }}
// Or apply to individual test@Test(DatabaseTrait())func specificTest() async throws { // Test code}Use when: Need async cleanup after each test.
Handling Flaky Tests
Section titled “Handling Flaky Tests”Problem: Race conditions
Section titled “Problem: Race conditions”@Test@MainActorfunc isLoadingState() async throws { let fetcher = ImageFetcher()
let task = Task { try await fetcher.fetch(url) }
// ❌ Flaky - may pass or fail #expect(fetcher.isLoading == true)
try await task.value #expect(fetcher.isLoading == false)}Issue: Task may complete before we check isLoading.
Solution: Swift Concurrency Extras
Section titled “Solution: Swift Concurrency Extras”import ConcurrencyExtras
@Test@MainActorfunc isLoadingState() async throws { try await withMainSerialExecutor { let fetcher = ImageFetcher { url in await Task.yield() // Allow test to check state return Data() }
let task = Task { try await fetcher.fetch(url) }
await Task.yield() // Switch to task
#expect(fetcher.isLoading == true) // ✅ Reliable
try await task.value #expect(fetcher.isLoading == false) }}Add package: https://github.com/pointfreeco/swift-concurrency-extras.git
Serial execution required
Section titled “Serial execution required”@Suite(.serialized)@MainActorfinal class ImageFetcherTests { // Tests run serially when using withMainSerialExecutor}Critical: Main serial executor doesn’t work with parallel test execution.
XCTest Patterns (Legacy)
Section titled “XCTest Patterns (Legacy)”Basic async test
Section titled “Basic async test”final class ArticleSearcherTests: XCTestCase { @MainActor func testEmptyQuery() async { let searcher = ArticleSearcher() await searcher.search("") XCTAssertEqual(searcher.results, ArticleSearcher.allArticles) }}Using expectations
Section titled “Using expectations”@MainActorfunc testSearchTask() async { let searcher = ArticleSearcher() let expectation = expectation(description: "Search complete")
_ = withObservationTracking { searcher.results } onChange: { expectation.fulfill() }
searcher.startSearchTask("swift")
// Use fulfillment, not wait await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(searcher.results.count, 1)}Critical: Use await fulfillment(of:), not wait(for:) to avoid deadlocks.
Setup and teardown
Section titled “Setup and teardown”final class DatabaseTests: XCTestCase { override func setUp() async throws { // Async setup }
override func tearDown() async throws { // Async teardown }}Mark as async throws to call async methods.
Main serial executor for all tests
Section titled “Main serial executor for all tests”final class MyTests: XCTestCase { override func invokeTest() { withMainSerialExecutor { super.invokeTest() } }}Common Patterns
Section titled “Common Patterns”Testing @MainActor code
Section titled “Testing @MainActor code”@Test@MainActorfunc viewModelUpdates() async { let viewModel = ViewModel() await viewModel.loadData() #expect(viewModel.items.count > 0)}Testing actors
Section titled “Testing actors”@Testfunc actorIsolation() async { let store = DataStore() await store.insert(item) let count = await store.count() #expect(count == 1)}Testing cancellation
Section titled “Testing cancellation”@Testfunc cancellationStopsWork() async throws { let processor = DataProcessor()
let task = Task { try await processor.processLargeDataset() }
task.cancel()
do { try await task.value Issue.record("Should have thrown cancellation error") } catch is CancellationError { // Expected }}Testing with delays
Section titled “Testing with delays”@Testfunc debouncedSearch() async throws { try await withMainSerialExecutor { let searcher = DebouncedSearcher()
searcher.search("a") await Task.yield()
searcher.search("ab") await Task.yield()
searcher.search("abc")
// Wait for debounce try await Task.sleep(for: .milliseconds(600))
#expect(searcher.searchCount == 1) // Only last search executed }}Testing task groups
Section titled “Testing task groups”@Testfunc taskGroupProcessesAll() async throws { let processor = BatchProcessor()
let results = await withTaskGroup(of: Int.self) { group in for i in 1...5 { group.addTask { await processor.process(i) } }
var collected: [Int] = [] for await result in group { collected.append(result) } return collected }
#expect(results.count == 5)}Testing Memory Management
Section titled “Testing Memory Management”Verify deallocation
Section titled “Verify deallocation”@Testfunc viewModelDeallocates() async { var viewModel: ViewModel? = ViewModel() weak var weakViewModel = viewModel
viewModel?.startWork() viewModel = nil
try? await Task.sleep(for: .milliseconds(100))
#expect(weakViewModel == nil)}Detect retain cycles
Section titled “Detect retain cycles”@Testfunc noRetainCycle() async { var manager: Manager? = Manager() weak var weakManager = manager
manager?.startLongRunningTask() manager = nil
#expect(weakManager == nil)}Best Practices
Section titled “Best Practices”- Use Swift Testing for new code - modern, better concurrency support
- Mark tests with correct isolation - @MainActor when needed
- Use confirmations over continuations - when structured concurrency allows
- Serialize tests with main serial executor - avoid flaky tests
- Test cancellation explicitly - ensure proper cleanup
- Verify deallocation - catch retain cycles early
- Use Task.yield() strategically - control execution in tests
- Avoid sleep in tests - use continuations/confirmations instead
- Test actor isolation - verify thread safety
- Keep tests deterministic - avoid timing dependencies
Migration from XCTest
Section titled “Migration from XCTest”XCTest → Swift Testing
Section titled “XCTest → Swift Testing”// XCTestfinal class MyTests: XCTestCase { func testExample() async { XCTAssertEqual(value, expected) }}
// Swift Testing@Suitestruct MyTests { @Test func example() async { #expect(value == expected) }}Expectations → Confirmations
Section titled “Expectations → Confirmations”// XCTestlet expectation = expectation(description: "Done")doWork { expectation.fulfill() }await fulfillment(of: [expectation])
// Swift Testingawait confirmation { confirm in await doWork { confirm() }}Setup/Teardown → Traits
Section titled “Setup/Teardown → Traits”// XCTestoverride func setUp() async throws { await prepare()}
// Swift Testingstruct SetupTrait: TestTrait, TestScoping { func provideScope( for test: Test, testCase: Test.Case?, performing function: () async throws -> Void ) async throws { await prepare() try await function() }}Troubleshooting
Section titled “Troubleshooting”Test hangs
Section titled “Test hangs”Cause: Waiting for expectation that never fulfills.
Solution: Add timeout, verify observation tracking.
Flaky test
Section titled “Flaky test”Cause: Race condition in unstructured task.
Solution: Use main serial executor + Task.yield().
Deadlock
Section titled “Deadlock”Cause: Using wait(for:) in async context.
Solution: Use await fulfillment(of:) instead.
Confirmation fails
Section titled “Confirmation fails”Cause: Not awaiting async work in confirmation block.
Solution: Add await before async calls.
Actor isolation error
Section titled “Actor isolation error”Cause: Test not marked with required actor.
Solution: Add @MainActor or appropriate actor to test.
Common Mistakes Agents Make
Section titled “Common Mistakes Agents Make”- Flaky intermediate-state assertions: Asserting
isLoading == trueimmediately after creating aTaskis a race condition — the task may not have started yet. UsewithMainSerialExecutor+Task.yield()to control scheduling before asserting intermediate state. - Using
Task.sleepas a synchronization primitive in tests instead of deterministic scheduling. - Asserting intermediate state without controlling scheduling: Always use
withMainSerialExecutorwhen you need to observe state between task creation and completion. Note:withMainSerialExecutordoes not work with parallel test execution — mark the suite@Suite(.serialized). - Reaching into isolated internals instead of testing public behavior.
- Keeping both Swift Testing and XCTest versions of the same example unless they teach different migration paths.
Testing Checklist
Section titled “Testing Checklist”- Tests marked with correct isolation
- Using Swift Testing (recommended)
- Async methods properly awaited
- Cancellation tested
- Memory leaks checked
- Race conditions handled
- Timeouts appropriate
- Flaky tests fixed with serial executor
- Actor isolation verified
- Cleanup in traits (not deinit)
Further Learning
Section titled “Further Learning”For advanced testing patterns, real-world examples, and migration strategies: