This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
359 lines
7.8 KiB
Markdown
359 lines
7.8 KiB
Markdown
---
|
|
name: axiom-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.
|
|
license: MIT
|
|
metadata:
|
|
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**: axiom-swift-testing, axiom-ios-testing
|