home / skills / derklinke / codex-config / ios-testing-async

ios-testing-async skill

/skills/ios-testing-async

This skill helps you test async Swift code with Swift Testing by validating callbacks, MainActor tests, and parallel execution.

npx playbooks add skill derklinke/codex-config --skill ios-testing-async

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
7.8 KB
---
name: testing-async
description: Use when testing async code with Swift Testing. Covers confirmation for callbacks, @MainActor tests, async/await patterns, timeout control, XCTest migration, parallel test execution.
skill_type: discipline
version: 1.0.0
---

# Testing Async Code — Swift Testing Patterns

Modern patterns for testing async/await code with Swift Testing framework.

## When to Use

✅ **Use when:**

- Writing tests for async functions
- Testing callback-based APIs with Swift Testing
- Migrating async XCTests to Swift Testing
- Testing MainActor-isolated code
- Need to verify events fire expected number of times

❌ **Don't use when:**

- XCTest-only project (use XCTestExpectation)
- UI automation tests (use XCUITest)
- Performance testing with metrics (use XCTest)

## Key Differences from XCTest

| XCTest | Swift Testing |
|--------|---------------|
| `XCTestExpectation` | `confirmation { }` |
| `wait(for:timeout:)` | `await confirmation` |
| `@MainActor` implicit | `@MainActor` explicit |
| Serial by default | **Parallel by default** |
| `XCTAssertEqual()` | `#expect()` |
| `continueAfterFailure` | `#require` per-expectation |

## Patterns

### Pattern 1: Simple Async Function

```swift
@Test func fetchUser() async throws {
    let user = try await api.fetchUser(id: 1)
    #expect(user.name == "Alice")
}
```

### Pattern 2: Completion Handler → Continuation

For APIs without async overloads:

```swift
@Test func legacyAPI() async throws {
    let result = try await withCheckedThrowingContinuation { continuation in
        legacyFetch { result, error in
            if let result {
                continuation.resume(returning: result)
            } else {
                continuation.resume(throwing: error!)
            }
        }
    }
    #expect(result.isValid)
}
```

### Pattern 3: Single Callback with confirmation

When a callback should fire exactly once:

```swift
@Test func notificationFires() async {
    await confirmation { confirm in
        NotificationCenter.default.addObserver(
            forName: .didUpdate,
            object: nil,
            queue: .main
        ) { _ in
            confirm()  // Must be called exactly once
        }
        triggerUpdate()
    }
}
```

### Pattern 4: Multiple Callbacks with expectedCount

```swift
@Test func delegateCalledMultipleTimes() async {
    await confirmation(expectedCount: 3) { confirm in
        delegate.onProgress = { progress in
            confirm()  // Called 3 times
        }
        startDownload()  // Triggers 3 progress updates
    }
}
```

### Pattern 5: Verify Callback Never Fires

```swift
@Test func noErrorCallback() async {
    await confirmation(expectedCount: 0) { confirm in
        delegate.onError = { _ in
            confirm()  // Should never be called
        }
        performSuccessfulOperation()
    }
}
```

### Pattern 6: MainActor Tests

```swift
@Test @MainActor func viewModelUpdates() async {
    let vm = ViewModel()
    await vm.load()
    #expect(vm.items.count > 0)
    #expect(vm.isLoading == false)
}
```

### Pattern 7: Timeout Control

```swift
@Test(.timeLimit(.seconds(5)))
func slowOperation() async throws {
    try await longRunningTask()
}
```

### Pattern 8: Testing Throws

```swift
@Test func invalidInputThrows() async throws {
    await #expect(throws: ValidationError.self) {
        try await validate(input: "")
    }
}

// Specific error
@Test func specificError() async throws {
    await #expect(throws: NetworkError.notFound) {
        try await api.fetch(id: -1)
    }
}
```

### Pattern 9: Optional Unwrapping with #require

```swift
@Test func firstVideo() async throws {
    let videos = try await videoLibrary.videos()
    let first = try #require(videos.first)  // Fails if nil
    #expect(first.duration > 0)
}
```

### Pattern 10: Parameterized Async Tests

```swift
@Test("Video loading", arguments: [
    "Beach.mov",
    "Mountain.mov",
    "City.mov"
])
func loadVideo(fileName: String) async throws {
    let video = try await Video.load(fileName)
    #expect(video.isPlayable)
}
```

Arguments run in **parallel** automatically.

## Parallel Test Execution

Swift Testing runs tests **in parallel by default** (unlike XCTest).

### Handling Shared State

```swift
// ❌ Shared mutable state — race condition
var sharedCounter = 0

@Test func test1() async {
    sharedCounter += 1  // Data race!
}

@Test func test2() async {
    sharedCounter += 1  // Data race!
}

// ✅ Each test gets fresh instance
struct CounterTests {
    var counter = Counter()  // Fresh per test

    @Test func increment() {
        counter.increment()
        #expect(counter.value == 1)
    }
}
```

### Forcing Serial Execution

When tests must run sequentially:

```swift
@Suite("Database tests", .serialized)
struct DatabaseTests {
    @Test func createRecord() async { /* ... */ }
    @Test func readRecord() async { /* ... */ }  // After create
    @Test func deleteRecord() async { /* ... */ }  // After read
}
```

**Note**: Other unrelated tests still run in parallel.

## Common Mistakes

### Mistake 1: Using sleep Instead of confirmation

```swift
// ❌ Flaky — arbitrary wait time
@Test func eventFires() async {
    setupEventHandler()
    try await Task.sleep(for: .seconds(1))  // Hope it happened?
    #expect(eventReceived)
}

// ✅ Deterministic — waits for actual event
@Test func eventFires() async {
    await confirmation { confirm in
        onEvent = { confirm() }
        triggerEvent()
    }
}
```

### Mistake 2: Forgetting @MainActor on UI Tests

```swift
// ❌ Data race — ViewModel may be MainActor
@Test func viewModel() async {
    let vm = ViewModel()
    await vm.load()  // May cause data race warnings
}

// ✅ Explicit isolation
@Test @MainActor func viewModel() async {
    let vm = ViewModel()
    await vm.load()
}
```

### Mistake 3: Missing confirmation for Callbacks

```swift
// ❌ Test passes immediately — doesn't wait for callback
@Test func callback() async {
    api.fetch { result in
        #expect(result.isSuccess)  // Never executed before test ends
    }
}

// ✅ Waits for callback
@Test func callback() async {
    await confirmation { confirm in
        api.fetch { result in
            #expect(result.isSuccess)
            confirm()
        }
    }
}
```

### Mistake 4: Not Handling Parallel Execution

```swift
// ❌ Tests interfere with each other
@Test func writeFile() async {
    try! "data".write(to: sharedFileURL, atomically: true, encoding: .utf8)
}

@Test func readFile() async {
    let data = try! String(contentsOf: sharedFileURL)  // May fail!
}

// ✅ Use unique files or .serialized
@Test func writeAndRead() async {
    let url = FileManager.default.temporaryDirectory
        .appendingPathComponent(UUID().uuidString)
    try! "data".write(to: url, atomically: true, encoding: .utf8)
    let data = try! String(contentsOf: url)
    #expect(data == "data")
}
```

## Migration from XCTest

### XCTestExpectation → confirmation

```swift
// XCTest
func testFetch() {
    let expectation = expectation(description: "fetch")
    api.fetch { result in
        XCTAssertNotNil(result)
        expectation.fulfill()
    }
    wait(for: [expectation], timeout: 5)
}

// Swift Testing
@Test func fetch() async {
    await confirmation { confirm in
        api.fetch { result in
            #expect(result != nil)
            confirm()
        }
    }
}
```

### Async setUp → Suite init

```swift
// XCTest
class MyTests: XCTestCase {
    var service: Service!

    override func setUp() async throws {
        service = try await Service.create()
    }
}

// Swift Testing
struct MyTests {
    let service: Service

    init() async throws {
        service = try await Service.create()
    }

    @Test func example() async {
        // Use self.service
    }
}
```

## Resources

**WWDC**: 2024-10179, 2024-10195

**Docs**: /testing, /testing/confirmation

**Skills**: swift-testing, ui-testing

Overview

This skill explains modern patterns for testing asynchronous Swift code using the Swift Testing framework. It covers confirmation-based callbacks, async/await conversions, @MainActor tests, timeout control, parallel execution, and migration tips from XCTest. The guidance focuses on deterministic, race-free tests and practical examples you can apply immediately.

How this skill works

The skill inspects common async testing scenarios and maps XCTest idioms to Swift Testing equivalents (for example, XCTestExpectation → confirmation). It shows how to wrap callback APIs with continuations, use confirmation blocks to await events, control timeouts with test attributes, and declare MainActor isolation for UI-related tests. It also highlights parallel execution behavior and strategies to serialize tests when needed.

When to use it

  • Testing async/await functions and Task-based code
  • Verifying callback-based APIs or converting them to async with continuations
  • Testing MainActor-isolated view models or UI-adjacent code
  • When you need deterministic waits for events (confirmation) rather than sleep
  • Migrating XCTest tests that use expectations to Swift Testing

Best practices

  • Use confirmation { confirm in ... } to await callbacks instead of Task.sleep or fixed delays
  • Wrap legacy completion handlers with withCheckedThrowingContinuation to get async/await semantics
  • Annotate UI or MainActor-bound tests with @MainActor to avoid data races
  • Design tests to avoid shared mutable state; use per-test instances or .serialized suites for sequential dependencies
  • Prefer #expect and #require for assertions and early failures inside async tests
  • Control test duration with @Test(.timeLimit(.seconds(n))) for long-running operations

Example use cases

  • Convert a completion-handler API to async with a checked continuation and assert the result
  • Wait for a notification or delegate callback exactly once using confirmation
  • Assert that a delegate method is called N times with confirmation(expectedCount: N)
  • Write @MainActor tests for ViewModel load flows and assert state updates
  • Serialize a database test suite using @Suite(..., .serialized) to run create/read/delete in order

FAQ

When should I use confirmation versus continuations?

Use continuations to adapt single-call completion handlers into async functions. Use confirmation when you need to wait for an external event, notification, or multiple callback invocations inside the test.

How do I prevent races when tests run in parallel?

Avoid shared mutable globals. Give each test its own instance or unique resources (temporary files, unique IDs). If ordering is required, group tests in a serialized @Suite.