--- name: axiom-swift-testing description: Use when writing unit tests, adopting Swift Testing framework, making tests run faster without simulator, architecting code for testability, testing async code reliably, or migrating from XCTest - covers @Test/@Suite macros, #expect/#require, parameterized tests, traits, tags, parallel execution, host-less testing license: MIT metadata: version: "1.0.0" last-updated: "WWDC 2024 (Swift Testing framework)" --- # Swift Testing ## Overview Swift Testing is Apple's modern testing framework introduced at WWDC 2024. It uses Swift macros (`@Test`, `#expect`) instead of naming conventions, runs tests in parallel by default, and integrates seamlessly with Swift concurrency. **Core principle**: Tests should be fast, reliable, and expressive. The fastest tests run without launching your app or simulator. ## The Speed Hierarchy Tests run at dramatically different speeds depending on how they're configured: | Configuration | Typical Time | Use Case | |---------------|--------------|----------| | `swift test` (Package) | ~0.1s | Pure logic, models, algorithms | | Host Application: None | ~3s | Framework code, no UI dependencies | | Bypass app launch | ~6s | App target but skip initialization | | Full app launch | 20-60s | UI tests, integration tests | **Key insight**: Move testable logic into Swift Packages or frameworks, then test with `swift test` or "None" host application. --- ## Building Blocks ### @Test Functions ```swift import Testing @Test func videoHasCorrectMetadata() { let video = Video(named: "example.mp4") #expect(video.duration == 120) } ``` **Key differences from XCTest**: - No `test` prefix required — `@Test` attribute is explicit - Can be global functions, not just methods in a class - Supports `async`, `throws`, and actor isolation - Each test runs on a fresh instance of its containing suite ### #expect and #require ```swift // Basic expectation — test continues on failure #expect(result == expected) #expect(array.isEmpty) #expect(numbers.contains(42)) // Required expectation — test stops on failure let user = try #require(await fetchUser(id: 123)) #expect(user.name == "Alice") // Unwrap optionals safely let first = try #require(items.first) #expect(first.isValid) ``` **Why #expect is better than XCTAssert**: - Captures source code and sub-values automatically - Single macro handles all operators (==, >, contains, etc.) - No need for specialized assertions (XCTAssertEqual, XCTAssertNil, etc.) ### Error Testing ```swift // Expect any error #expect(throws: (any Error).self) { try dangerousOperation() } // Expect specific error type #expect(throws: NetworkError.self) { try fetchData() } // Expect specific error value #expect(throws: ValidationError.invalidEmail) { try validate(email: "not-an-email") } // Custom validation #expect { try process(data) } throws: { error in guard let networkError = error as? NetworkError else { return false } return networkError.statusCode == 404 } ``` ### @Suite Types ```swift @Suite("Video Processing Tests") struct VideoTests { let video = Video(named: "sample.mp4") // Fresh instance per test @Test func hasCorrectDuration() { #expect(video.duration == 120) } @Test func hasCorrectResolution() { #expect(video.resolution == CGSize(width: 1920, height: 1080)) } } ``` **Key behaviors**: - Structs preferred (value semantics, no accidental state sharing) - Each `@Test` gets its own suite instance - Use `init` for setup, `deinit` for teardown (actors/classes only) - Nested suites supported for organization --- ## Traits Traits customize test behavior: ```swift // Display name @Test("User can log in with valid credentials") func loginWithValidCredentials() { } // Disable with reason @Test(.disabled("Waiting for backend fix")) func brokenFeature() { } // Conditional execution @Test(.enabled(if: FeatureFlags.newUIEnabled)) func newUITest() { } // Time limit @Test(.timeLimit(.minutes(1))) func longRunningTest() async { } // Bug reference @Test(.bug("https://github.com/org/repo/issues/123", "Flaky on CI")) func sometimesFailingTest() { } // OS version requirement @available(iOS 18, *) @Test func iOS18OnlyFeature() { } ``` ### Tags for Organization ```swift // Define tags extension Tag { @Tag static var networking: Self @Tag static var performance: Self @Tag static var slow: Self } // Apply to tests @Test(.tags(.networking, .slow)) func networkIntegrationTest() async { } // Apply to entire suite @Suite(.tags(.performance)) struct PerformanceTests { @Test func benchmarkSort() { } // Inherits .performance tag } ``` **Use tags to**: - Run subsets of tests (filter by tag in Test Navigator) - Exclude slow tests from quick feedback loops - Group related tests across different files/suites --- ## Parameterized Testing Transform repetitive tests into a single parameterized test: ```swift // ❌ Before: Repetitive @Test func vanillaHasNoNuts() { #expect(!IceCream.vanilla.containsNuts) } @Test func chocolateHasNoNuts() { #expect(!IceCream.chocolate.containsNuts) } @Test func almondHasNuts() { #expect(IceCream.almond.containsNuts) } // ✅ After: Parameterized @Test(arguments: [IceCream.vanilla, .chocolate, .strawberry]) func flavorWithoutNuts(_ flavor: IceCream) { #expect(!flavor.containsNuts) } @Test(arguments: [IceCream.almond, .pistachio]) func flavorWithNuts(_ flavor: IceCream) { #expect(flavor.containsNuts) } ``` ### Two-Collection Parameterization ```swift // Test all combinations (4 × 3 = 12 test cases) @Test(arguments: [1, 2, 3, 4], ["a", "b", "c"]) func allCombinations(number: Int, letter: String) { // Tests: (1,"a"), (1,"b"), (1,"c"), (2,"a"), ... } // Test paired values only (3 test cases) @Test(arguments: zip([1, 2, 3], ["one", "two", "three"])) func pairedValues(number: Int, name: String) { // Tests: (1,"one"), (2,"two"), (3,"three") } ``` ### Benefits Over For-Loops | For-Loop | Parameterized | |----------|---------------| | Stops on first failure | All arguments run | | Unclear which value failed | Each argument shown separately | | Sequential execution | Parallel execution | | Can't re-run single case | Re-run individual arguments | --- ## Fast Tests: Architecture for Testability ### Strategy 1: Swift Package for Logic (Fastest) Extract app logic into a Swift Package. Tests run with `swift test` (~0.4s) instead of `xcodebuild test` (~25s) — no simulator, no app launch. This is the key enabler for TDD in Claude Code hooks. #### Step 1: Create Package.swift Create the package directory alongside your `.xcodeproj`: ```swift // MyAppCore/Package.swift // swift-tools-version: 6.0 import PackageDescription let package = Package( name: "MyAppCore", platforms: [.iOS(.v18), .macOS(.v15)], products: [ .library(name: "MyAppCore", targets: ["MyAppCore"]), ], targets: [ .target(name: "MyAppCore"), .testTarget(name: "MyAppCoreTests", dependencies: ["MyAppCore"]), ] ) ``` #### Step 2: Link Package to App Create an `.xcworkspace` containing both the app project and the package: 1. File → New → Workspace 2. Drag your `.xcodeproj` into the workspace 3. File → Add Package Dependencies → Add Local → select `MyAppCore/` 4. Add `MyAppCore` framework to your app target's "Frameworks, Libraries, and Embedded Content" #### Step 3: Move Logic, Expose Root View Move models, services, and view models into `MyAppCore/Sources/MyAppCore/`. Types used by the app must be `public`. Create a public root view that accepts dependencies via injection: ```swift // In MyAppCore public struct MyAppRootView: View { @State private var appState: AppStateController public init(modelContainer: ModelContainer) { _appState = State(initialValue: AppStateController(container: modelContainer)) } public var body: some View { /* ... */ } } ``` #### Step 4: Thin-Shell App.swift The app target becomes a thin shell that imports the package and delegates (see `axiom-app-composition` for the full thin-shell principle): ```swift import SwiftUI import MyAppCore @main struct MyApp: App { let container = try! ModelContainer(for: /* schemas */) var body: some Scene { WindowGroup { MyAppRootView(modelContainer: container) } } } ``` #### What Stays vs What Moves | Stays in App Target | Moves to Package | |---------------------|------------------| | `@main` App.swift (thin shell) | Models, view models, services | | Asset catalogs, resources | Business logic, algorithms | | Info.plist, entitlements | Navigation, state management | | Launch screen | Utilities, extensions | Tests use `@testable import MyAppCore` for internal access. #### Running Tests ```bash cd MyAppCore swift test # All tests (~0.4s) swift test --filter MyAppCoreTests.UserTests # Single suite ``` For project-level scripts separating unit from UI tests: ```bash # script/test #!/bin/bash case "${1:-unit}" in unit) cd MyAppCore && swift test ;; ui) xcodebuild test -workspace MyApp.xcworkspace \ -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' ;; esac ``` #### Progressive Extraction for Existing Projects For apps that can't extract everything at once, move modules incrementally: #### Phase 1: Leaf Modules First Start with code that has no dependencies on the app target: - Data models and DTOs - Networking layer (API clients, request builders) - Business logic and validation rules - Utility extensions #### Phase 2: Break Circular Dependencies If package code needs to call back into app-owned types: 1. Define a protocol in the package (the package owns the abstraction) 2. Inject a conforming implementation from the app target at startup 3. Move the implementation into the package once all its dependencies are in the package #### Phase 3: Maintain Both Test Targets During transition, keep two test targets: - `MyAppCoreTests` — runs with `swift test` (extracted logic) - `MyAppTests` — runs with `xcodebuild test` (remaining app-level tests) Gradually migrate tests from `MyAppTests` to `MyAppCoreTests` as you extract their source files. **Goal**: Each extraction should leave the app building and all tests passing. Never extract more than one module boundary at a time. ### Strategy 2: Framework with No Host Application For code that must stay in the app project: 1. **Create a framework target** (File → New → Target → Framework) 2. **Move model code** into the framework 3. **Make types public** that need external access 4. **Add imports** in files using the framework 5. **Set Host Application to "None"** in test target settings ``` Project Settings → Test Target → Testing Host Application: None ← Key setting ☐ Allow testing Host Application APIs ``` Build+test time: ~3 seconds vs 20-60 seconds with app launch. ### Strategy 3: Bypass SwiftUI App Launch If you can't use a framework, bypass the app launch: ```swift // Simple solution (no custom startup code) @main struct ProductionApp: App { var body: some Scene { WindowGroup { if !isRunningTests { ContentView() } } } private var isRunningTests: Bool { NSClassFromString("XCTestCase") != nil } } ``` ```swift // Thorough solution (custom startup code) @main struct MainEntryPoint { static func main() { if NSClassFromString("XCTestCase") != nil { TestApp.main() // Empty app for tests } else { ProductionApp.main() } } } struct TestApp: App { var body: some Scene { WindowGroup { } // Empty } } ``` --- ## Async Testing ### Basic Async Tests ```swift @Test func fetchUserReturnsData() async throws { let user = try await userService.fetch(id: 123) #expect(user.name == "Alice") } ``` ### Testing Callbacks with Continuations ```swift // Convert completion handler to async @Test func legacyAPIWorks() async throws { let result = try await withCheckedThrowingContinuation { continuation in legacyService.fetchData { result in continuation.resume(with: result) } } #expect(result.count > 0) } ``` ### Confirmations for Multiple Events ```swift @Test func cookiesAreEaten() async { await confirmation("cookie eaten", expectedCount: 10) { confirm in let jar = CookieJar(count: 10) jar.onCookieEaten = { confirm() } await jar.eatAll() } } // Confirm something never happens await confirmation(expectedCount: 0) { confirm in let cache = Cache() cache.onEviction = { confirm() } cache.store("small-item") // Should not trigger eviction } ``` ### Reliable Async Testing with Concurrency Extras **Problem**: Async tests can be flaky due to scheduling unpredictability. ```swift // ❌ Flaky: Task scheduling is unpredictable @Test func loadingStateChanges() async { let model = ViewModel() let task = Task { await model.loadData() } #expect(model.isLoading == true) // Often fails! await task.value } ``` **Solution**: Use Point-Free's `swift-concurrency-extras`: ```swift import ConcurrencyExtras @Test func loadingStateChanges() async { await withMainSerialExecutor { let model = ViewModel() let task = Task { await model.loadData() } await Task.yield() #expect(model.isLoading == true) // Deterministic! await task.value #expect(model.isLoading == false) } } ``` **Why it works**: Serializes async work to main thread, making suspension points deterministic. ### Deterministic Time with TestClock Use Point-Free's `swift-clocks` to control time in tests: ```swift import Clocks @MainActor class FeatureModel: ObservableObject { @Published var count = 0 let clock: any Clock var timerTask: Task? init(clock: any Clock) { self.clock = clock } func startTimer() { timerTask = Task { while true { try await clock.sleep(for: .seconds(1)) count += 1 } } } } // Test with controlled time @Test func timerIncrements() async { let clock = TestClock() let model = FeatureModel(clock: clock) model.startTimer() await clock.advance(by: .seconds(1)) #expect(model.count == 1) await clock.advance(by: .seconds(4)) #expect(model.count == 5) model.timerTask?.cancel() } ``` **Clock types**: - `TestClock` — Advance time manually, deterministic - `ImmediateClock` — All sleeps return instantly (great for previews) - `UnimplementedClock` — Fails if used (catch unexpected time dependencies) --- ## Parallel Testing Swift Testing runs tests in parallel by default. ### When to Serialize ```swift // Serialize tests in a suite that share external state @Suite(.serialized) struct DatabaseTests { @Test func createUser() { } @Test func deleteUser() { } // Runs after createUser } // Serialize parameterized test cases @Test(.serialized, arguments: [1, 2, 3]) func sequentialProcessing(value: Int) { } ``` ### Hidden Dependencies ```swift // ❌ Bug: Tests depend on execution order @Suite struct CookieTests { static var cookie: Cookie? @Test func bakeCookie() { Self.cookie = Cookie() // Sets shared state } @Test func eatCookie() { #expect(Self.cookie != nil) // Fails if runs first! } } // ✅ Fixed: Each test is independent @Suite struct CookieTests { @Test func bakeCookie() { let cookie = Cookie() #expect(cookie.isBaked) } @Test func eatCookie() { let cookie = Cookie() cookie.eat() #expect(cookie.isEaten) } } ``` **Random order** helps expose these bugs — fix them rather than serialize. --- ## Known Issues Handle expected failures without noise: ```swift @Test func featureUnderDevelopment() { withKnownIssue("Backend not ready yet") { try callUnfinishedAPI() } } // Conditional known issue @Test func platformSpecificBug() { withKnownIssue("Fails on iOS 17.0") { try reproduceEdgeCaseBug() } when: { ProcessInfo().operatingSystemVersion.majorVersion == 17 } } ``` **Better than .disabled because**: - Test still compiles (catches syntax errors) - You're notified when the issue is fixed - Results show "expected failure" not "skipped" --- ## Migration from XCTest ### Comparison Table | XCTest | Swift Testing | |--------|---------------| | `func testFoo()` | `@Test func foo()` | | `XCTAssertEqual(a, b)` | `#expect(a == b)` | | `XCTAssertNil(x)` | `#expect(x == nil)` | | `XCTAssertThrowsError` | `#expect(throws:)` | | `XCTUnwrap(x)` | `try #require(x)` | | `class FooTests: XCTestCase` | `@Suite struct FooTests` | | `setUp()` / `tearDown()` | `init` / `deinit` | | `continueAfterFailure = false` | `#require` (per-expectation) | | `addTeardownBlock` | `deinit` or defer | ### Keep Using XCTest For - **UI tests** (XCUIApplication) - **Performance tests** (XCTMetric) - **Objective-C tests** ### Migration Tips 1. Both frameworks can coexist in the same target 2. Migrate incrementally, one test file at a time 3. Consolidate similar XCTests into parameterized Swift tests 4. Single-test XCTestCase → global `@Test` function --- ## Common Mistakes ### ❌ Mixing Assertions ```swift // Don't mix XCTest and Swift Testing @Test func badExample() { XCTAssertEqual(1, 1) // ❌ Wrong framework #expect(1 == 1) // ✅ Use this } ``` ### ❌ Using Classes for Suites ```swift // ❌ Avoid: Reference semantics can cause shared state bugs @Suite class VideoTests { } // ✅ Prefer: Value semantics isolate each test @Suite struct VideoTests { } ``` ### ❌ Forgetting @MainActor ```swift // ❌ May fail with Swift 6 strict concurrency @Test func updateUI() async { viewModel.updateTitle("New") // Data race warning } // ✅ Isolate to main actor @Test @MainActor func updateUI() async { viewModel.updateTitle("New") } ``` ### ❌ Over-Serializing ```swift // ❌ Don't serialize just because tests use async @Suite(.serialized) struct APITests { } // Defeats parallelism // ✅ Only serialize when tests truly share mutable state ``` ### ❌ XCTestCase with Swift 6.2 MainActor Default Swift 6.2's `default-actor-isolation = MainActor` breaks XCTestCase: ```swift // ❌ Error: Main actor-isolated initializer 'init()' has different // actor isolation from nonisolated overridden declaration final class PlaygroundTests: XCTestCase { override func setUp() async throws { try await super.setUp() } } ``` **Solution**: Mark XCTestCase subclass as `nonisolated`: ```swift // ✅ Works with MainActor default isolation nonisolated final class PlaygroundTests: XCTestCase { @MainActor override func setUp() async throws { try await super.setUp() } @Test @MainActor func testSomething() async { // Individual tests can be @MainActor } } ``` **Why**: XCTestCase is Objective-C, not annotated for Swift concurrency. Its initializers are `nonisolated`, causing conflicts with MainActor-isolated subclasses. **Better solution**: Migrate to Swift Testing (`@Suite struct`) which handles isolation properly. --- ## Xcode Optimization for Fast Feedback ### Turn Off Parallel XCTest Execution Swift Testing runs in parallel by default; XCTest parallelization adds overhead: ``` Test Plan → Options → Parallelization → "Swift Testing Only" ``` ### Turn Off Test Debugger Attaching the debugger costs ~1 second per run: ``` Scheme → Edit Scheme → Test → Info → ☐ Debugger ``` ### Delete UI Test Templates Xcode's default UI tests slow everything down. Remove them: 1. Delete UI test target (Project Settings → select target → -) 2. Delete UI test source folder ### Disable dSYM for Debug Builds ``` Build Settings → Debug Information Format Debug: DWARF Release: DWARF with dSYM File ``` ### Check Build Scripts Run Script phases without defined inputs/outputs cause full rebuilds. Always specify: - Input Files / Input File Lists - Output Files / Output File Lists --- ## Checklist ### Before Writing Tests - [ ] Identify what can move to a Swift Package (pure logic) - [ ] Set up framework target if package isn't viable - [ ] Configure Host Application: None for unit tests ### Writing Tests - [ ] Use `@Test` with clear display names - [ ] Use `#expect` for all assertions - [ ] Use `#require` to fail fast on preconditions - [ ] Use parameterization for similar test cases - [ ] Add `.tags()` for organization ### Async Tests - [ ] Mark test functions `async` and use `await` - [ ] Use `confirmation()` for callback-based code - [ ] Consider `withMainSerialExecutor` for flaky tests ### Parallel Safety - [ ] Avoid shared mutable state between tests - [ ] Use fresh instances in each test - [ ] Only use `.serialized` when absolutely necessary --- ## Resources **WWDC**: 2024-10179, 2024-10195 **Docs**: /testing, /testing/migratingfromxctest, /testing/testing-asynchronous-code, /testing/parallelization **GitHub**: pointfreeco/swift-concurrency-extras, pointfreeco/swift-clocks --- **History:** See git log for changes