.agents/skills/swift-concurrency/references/testing.md
Use this when:
Skip this file if:
actors.md, tasks.md, or memory-management.md.Jump to:
Swift Testing is strongly recommended for new projects and tests. It provides:
XCTest patterns are included for legacy codebases.
@Test
@MainActor
func emptyQuery() async {
let searcher = ArticleSearcher()
await searcher.search("")
#expect(searcher.results == ArticleSearcher.allArticles)
}
Key differences from XCTest:
@Test macro instead of XCTestCase#expect instead of XCTAsserttest prefix required@Test
@MainActor
func searchReturnsResults() async {
let searcher = ArticleSearcher()
await searcher.search("swift")
#expect(!searcher.results.isEmpty)
}
Mark test with actor if system under test requires it.
Course Deep Dive: This topic is covered in detail in Lesson 11.2: Testing concurrent code using Swift Testing
When testing unstructured tasks:
@Test
@MainActor
func 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.
For structured async code:
@Test
@MainActor
func 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.
@MainActor
final 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.
For async teardown:
@MainActor
struct 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
@MainActor
struct Environment {
@TaskLocal static var database = Database()
}
// Apply to suite
@Suite(DatabaseTrait())
@MainActor
final 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.
@Test
@MainActor
func 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.
import ConcurrencyExtras
@Test
@MainActor
func 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
Course Deep Dive: This topic is covered in detail in Lesson 11.3: Using Swift Concurrency Extras by Point-Free
@Suite(.serialized)
@MainActor
final class ImageFetcherTests {
// Tests run serially when using withMainSerialExecutor
}
Critical: Main serial executor doesn't work with parallel test execution.
final class ArticleSearcherTests: XCTestCase {
@MainActor
func testEmptyQuery() async {
let searcher = ArticleSearcher()
await searcher.search("")
XCTAssertEqual(searcher.results, ArticleSearcher.allArticles)
}
}
@MainActor
func 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.
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.
Course Deep Dive: This topic is covered in detail in Lesson 11.1: Testing concurrent code using XCTest
final class MyTests: XCTestCase {
override func invokeTest() {
withMainSerialExecutor {
super.invokeTest()
}
}
}
@Test
@MainActor
func viewModelUpdates() async {
let viewModel = ViewModel()
await viewModel.loadData()
#expect(viewModel.items.count > 0)
}
@Test
func actorIsolation() async {
let store = DataStore()
await store.insert(item)
let count = await store.count()
#expect(count == 1)
}
@Test
func 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
}
}
@Test
func 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
}
}
@Test
func 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)
}
@Test
func viewModelDeallocates() async {
var viewModel: ViewModel? = ViewModel()
weak var weakViewModel = viewModel
viewModel?.startWork()
viewModel = nil
try? await Task.sleep(for: .milliseconds(100))
#expect(weakViewModel == nil)
}
@Test
func noRetainCycle() async {
var manager: Manager? = Manager()
weak var weakManager = manager
manager?.startLongRunningTask()
manager = nil
#expect(weakManager == nil)
}
// XCTest
final class MyTests: XCTestCase {
func testExample() async {
XCTAssertEqual(value, expected)
}
}
// Swift Testing
@Suite
struct MyTests {
@Test
func example() async {
#expect(value == expected)
}
}
// XCTest
let expectation = expectation(description: "Done")
doWork { expectation.fulfill() }
await fulfillment(of: [expectation])
// Swift Testing
await confirmation { confirm in
await doWork { confirm() }
}
// XCTest
override func setUp() async throws {
await prepare()
}
// Swift Testing
struct SetupTrait: TestTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: () async throws -> Void
) async throws {
await prepare()
try await function()
}
}
Cause: Waiting for expectation that never fulfills.
Solution: Add timeout, verify observation tracking.
Cause: Race condition in unstructured task.
Solution: Use main serial executor + Task.yield().
Cause: Using wait(for:) in async context.
Solution: Use await fulfillment(of:) instead.
Cause: Not awaiting async work in confirmation block.
Solution: Add await before async calls.
Cause: Test not marked with required actor.
Solution: Add @MainActor or appropriate actor to test.
isLoading == true immediately after creating a Task is a race condition — the task may not have started yet. Use withMainSerialExecutor + Task.yield() to control scheduling before asserting intermediate state.Task.sleep as a synchronization primitive in tests instead of deterministic scheduling.withMainSerialExecutor when you need to observe state between task creation and completion. Note: withMainSerialExecutor does not work with parallel test execution — mark the suite @Suite(.serialized).For advanced testing patterns, real-world examples, and migration strategies: