# The Ultimate Swift Testing Playbook A hands-on checklist for migrating from XCTest to Swift Testing and leveraging the full power of the new API. This guide focuses on what you can do with the framework to make your tests more powerful, expressive, and maintainable. --- ## **1. Migration & Tooling Baseline** Ensure your environment is set up for a smooth, gradual migration. | What | Why | |---|---| | **Xcode 16 & Swift 6** | Swift Testing is bundled with the latest toolchain. Older versions will not compile. | | **Keep XCTest Targets** | Allows for a file-by-file migration. You can run new and legacy tests side-by-side, which is crucial for CI stability. | | **Enable Parallel Execution**| In your Test Plan, enable "Use parallel execution" to take immediate advantage of Swift Testing's default concurrency model. | ### Migration Action Items - [ ] Ensure all developer machines and CI runners are on **macOS 15+** and Xcode 16.3+. - [ ] For any projects supporting Linux/Windows, add the `swift-testing` SPM package. It's not needed for Apple platforms. - [ ] In your primary test plan, flip the switch to **“Use parallel execution”**. --- ## **2. Expressive Assertions: `#expect` & `#require`** Replace the entire `XCTAssert` family with two powerful, expressive macros. | Macro | Use Case & Behavior | |---|---| | **`#expect(expression)`** | **Soft Check.** Use this for most validations. If the expression fails, the issue is recorded, but the test function continues executing, allowing you to find multiple failures in a single run. | | **`#require(expression)`**| **Hard Check.** Use this for critical preconditions. If the expression fails or throws, the test is immediately aborted. This prevents cascading failures from a failed setup. | ### Power Move: Optional-Safe Unwrapping `#require` is the new, safer replacement for `XCTUnwrap`. It not only checks for `nil` but also unwraps the value for subsequent use if the check passes. ```swift // Old XCTest way let user = try XCTUnwrap(await fetchUser()) // New, safer Swift Testing way let user = try #require(await fetchUser()) // `user` is now a non-optional User, ready for further assertions. #expect(user.age == 37) ``` ### Action Items - [ ] Run `grep -R "XCTAssert" .` on your project to find all legacy assertions. - [ ] Convert `XCTUnwrap` calls to `try #require()`. - [ ] Convert most `XCTAssert` calls to `#expect()`. Use `#require()` for preconditions that would make the rest of the test invalid. --- ## **3. Setup, Teardown, and State Lifecycle** Swift Testing replaces the `setUpWithError` and `tearDownWithError` methods with a more natural, type-safe lifecycle using `init()` and `deinit`. **The Core Concept:** A fresh, new instance of the test suite `struct` or `class` is created for **each** test function it contains. This is the cornerstone of test isolation, guaranteeing that state from one test cannot leak into another. | Method | Replaces... | Behavior | |---|---|---| | `init()` | `setUpWithError()` | The initializer for your suite. Put all setup code here: create the System Under Test (SUT), prepare mocks, and establish the initial state. | | `deinit` | `tearDownWithError()` | The deinitializer for your suite. Put all cleanup code here, such as deleting temporary files or invalidating resources. It runs automatically after each test completes. | ### Practical Example: A Database Test Suite ```swift @Suite struct DatabaseServiceTests { let sut: DatabaseService let tempDirectory: URL init() { // ARRANGE: Runs before EACH test in this suite. self.tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try! FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) let testDatabase = TestDatabase(storageURL: tempDirectory) self.sut = DatabaseService(database: testDatabase) } deinit { // TEARDOWN: Runs after EACH test in this suite. try? FileManager.default.removeItem(at: tempDirectory) } @Test func testSavingUser() throws { let user = User(id: "user-1", name: "Alex") try sut.save(user) #expect(try sut.loadUser(id: "user-1") != nil) } @Test func testDeletingUser() throws { // A completely separate, clean instance of the suite runs for this test. } } ``` ### Migration Action Items - [ ] Convert test classes inheriting from `XCTestCase` to `struct`s or `final class`es. - [ ] Move logic from `setUpWithError` into the suite's `init()`. - [ ] Move cleanup logic from `tearDownWithError` into the suite's `deinit`. - [ ] Define the SUT and its dependencies as `let` properties on the suite, initialized inside `init()`. --- ## **4. Mastering Error Handling** Go beyond simple `do/catch` blocks with a dedicated, expressive API for error validation. | Overload | Replaces... | Example & Use Case | |---|---|---| | **`#expect(throws: Error.self)`**| Basic `XCTAssertThrowsError` | Verifies that *any* error was thrown. | | **`#expect(throws: BrewingError.self)`** | Typed `XCTAssertThrowsError` | Ensures an error of a specific *type* is thrown. | | **`#expect(throws: BrewingError.outOfBeans)`**| Specific Error `XCTAssertThrowsError`| Validates a specific error value is thrown. | | **`#expect(throws: Never.self)`** | `XCTAssertNoThrow` | Explicitly asserts that a function does *not* throw. | | **`#require(throws:)`** | Critical `XCTAssertThrowsError` | A hard check that halts the test if the expected error is *not* thrown. | --- ## **5. Parameterized Tests: Kill the Copy-Paste** Drastically reduce boilerplate by running a single test function with multiple argument sets. | Pattern | How to Use It | |---|---| | **Basic Arguments** | `@Test(arguments: [0, 100, -40])` Pass a simple collection. Each element is a separate test case. | | **Zipped Collections** | `@Test(arguments: zip(inputs, expected))` Use `zip` to pair inputs and outputs for validation, avoiding a combinatorial explosion. This is the most common pattern. | | **Multiple Collections** | `@Test(arguments: ["USD", "EUR"],)` Creates a test case for every possible combination (Cartesian Product). | --- ## **6. Structure and Organization at Scale** Use suites and tags to manage large and complex test bases. ### Suites and Nested Suites A `@Suite` groups related tests. They can be nested to create a clear structural hierarchy. Traits applied to a suite are inherited by all tests within it. ```swift @Suite("API Services", .tags(.network)) struct APITests { @Suite("Authentication") struct AuthTests { /* ... */ } } ``` ### Tags for Cross-Cutting Concerns Tags associate tests that share common characteristics, regardless of their suite. 1. **Define Tags in a Central File:** ```swift // /Tests/Support/TestTags.swift import Testing extension Tag { @Tag static var fast: Self @Tag static var regression: Self } ``` 2. **Apply Tags & Filter:** ```swift @Test("Username validation", .tags(.fast, .regression)) // Apply // swift test --filter-tag fast // Run from CLI ``` --- ## **7. Concurrency and Asynchronous Testing** ### Async Tests Simply mark your test function `async` and use `await`. ```swift @Test("User profile downloads correctly") async func testProfileDownload() async throws { /* ... */ } ``` ### Confirmations for Multiple Callbacks To test APIs that fire multiple times (like streams or event handlers), use `confirmation`. ```swift @Test("Data stream sends three packets") async func testDataStream() async { let streamFinished = confirmation("Stream sent 3 packets", expectedCount: 3) // ... } ``` ### Controlling Parallelism with `.serialized` Apply the `.serialized` trait to any `@Test` or `@Suite` to opt out of concurrent execution for tests that are not thread-safe. ### Preventing Runaway Tests with Time Limits The `.timeLimit` trait is a safety net to prevent hung tests from stalling your entire CI pipeline, especially those involving `async` operations. * **How it works:** It sets a maximum duration for a single test's execution. If the test exceeds this limit, it immediately fails. * **Behavior:** When a suite and a test within it both have a time limit, the **more restrictive (shorter) duration wins**. * **Units:** The duration is highly flexible (e.g., `.seconds(5)`, `.milliseconds(500)`). ```swift // Suite-level timeout of 10 seconds for all network tests @Suite("Network Fetcher", .timeLimit(.seconds(10))) struct NetworkFetcherTests { @Test("Fetching a large file has a generous timeout") func testLargeFileDownload() async { /* Inherits 10-second limit */ } // This specific test must complete in under 1 second, overriding the suite's default. @Test("A fast API status check", .timeLimit(.seconds(1))) func testFastAPI() async { /* ... */ } } ``` --- ## **8. Advanced API Cookbook** | Feature | What it Does & How to Use It | |---|---| | **`CustomTestStringConvertible`** | Provides custom, readable descriptions for your types in test failure logs. Conform your key models to this to make debugging easier. | | **`withKnownIssue`** | Marks a test as an "Expected Failure" due to a known bug. The test runs but won't fail the suite. If the bug gets fixed and the test passes, `withKnownIssue` will fail, alerting you to remove it. | | **`.bug("JIRA-123")` Trait** | Associates a test directly with a ticket in your issue tracker, adding valuable context to test reports. | | **`#expectAll { ... }`**| Groups multiple assertions. If any assertion inside the block fails, they are all reported together, but execution continues past the block. | --- ## **9. CI and Command-Line Recipes** * **Run All Tests**: `swift test --enable-swift-testing` * **Filter by Tag**: `swift test --filter-tag regression` * **Generate JUnit Report**: `swift test --enable-swift-testing --format junit > report.xml` * **Check Code Coverage**: `swift test --enable-swift-testing --show-code-coverage` * **Xcode Cloud**: Natively supports Swift Testing, including test plans and tag-based analytics, with no extra flags needed. Of course. Here are the requested sections, formatted as distinct blocks with detailed explanations and practical examples, ready to be integrated into the main guide. *** ### Memory-Safety Patterns Ensuring your code is free of memory leaks and retain cycles is critical. Swift Testing offers modern, pattern-matching ways to validate memory safety, replacing older XCTest techniques. | XCTest Pattern | Swift Testing Equivalent & Explanation | | :--- | :--- | | `addTeardownBlock { [weak obj] … }` | **Use `deinit` to assert on weak references.** Because a new suite instance is created for each test and deinitialized afterward, you can place memory checks directly in `deinit`. This is cleaner and more idiomatic.

```swift @Suite struct MyViewControllerTests { var strongVC: MyViewController? init() { self.strongVC = MyViewController() } deinit { // This runs after the test, once the suite instance is discarded. #expect(self.strongVC == nil, "MyViewController should have been deallocated.") } @Test func testVCLifecycle() { // The test can hold a weak reference to the object. weak var weakVC = strongVC #expect(weakVC != nil) // `strongVC` is released when the suite deinitializes. } } ``` | | `expectation.isInverted = true` | **Use a `confirmation` with an expected count of 0.** An inverted expectation in XCTest was used to assert something *didn't* happen. The modern equivalent is to create a `confirmation` that is expected to be fulfilled zero times.

```swift @Test("Delegate method should not be called") async func testDelegateNotCalled() { let confirmation = confirmation("delegate.didFail was not called", expectedCount: 0) let delegate = MockDelegate(onFail: { await confirmation() }) let sut = SystemUnderTest(delegate: delegate) sut.performSuccessfulOperation() // Await a short duration to allow for any potential async calls. try? await Task.sleep(for: .milliseconds(100)) } ``` | --- ### `#expect` Power-Moves The `#expect` macro is more than a simple boolean check. Its specialized overloads allow you to write more expressive and concise tests, eliminating verbose helper code and manual validation logic. | Overload & Example | Replaces... | Handy For... | | :--- | :--- | :--- | | **`#expect(throws: SomeError.self)`** | `XCTAssertThrowsError` | Validating error paths concisely. The test passes only if the specified error type is thrown. | | **`#expect(throws: Never.self)`** | `XCTAssertNoThrow` | Asserting that an operation successfully completes without any errors. This is ideal for "happy path" tests. | | **`#expect(performing:throws:)`** | Manual `do/catch` with pattern matching | **Error Payload Introspection.** This overload lets you provide a secondary closure to inspect the properties of a thrown error, which is perfect for validating errors with associated values.

```swift #expect(performing: { try brewCoffee(with: .notEnoughBeans(needed: 10)) }, throws: { (error: BrewingError) in guard case let .notEnoughBeans(needed) = error else { return false } return needed == 10 }) ``` | | **`#expectAll { ... }`** | Multiple, separate `#expect` calls or a `for` loop of assertions. | **Grouping Failures.** When you need to run several assertions that are logically connected, wrapping them in `#expectAll` ensures that all assertions are executed, and all failures are reported together, even if the first one fails.

```swift #expectAll { #expect(user.name == "Alex") #expect(user.age == 37) #expect(user.isPremium == true) } ``` | --- ### Conditional Skips & Execution Swift Testing provides powerful traits to conditionally run or skip tests, which is essential for managing tests that depend on feature flags, specific environments, or are temporarily flaky. | Trait | What It Does & How to Use It | | :--- | :--- | | **`.disabled("Reason")`** | **Unconditionally skips a test.** The test will not run, and will be marked as "Skipped" in the test report. Always provide a descriptive reason.

```swift @Test(.disabled("Flaky on CI, see FB12345")) func testFlakyFeature() { /* ... */ } ``` | | **`.enabled(if: condition)`** | **Conditionally runs a test.** This is the most powerful option for dynamic test execution. The test only runs if the boolean `condition` is `true`. This is perfect for tests related to feature flags.

```swift struct FeatureFlags { static var isNewPaymentsAPIEnabled: Bool { // Logic to check remote config, etc. return ProcessInfo.processInfo.environment["PAYMENTS_ENABLED"] == "1" } } @Test(.enabled(if: FeatureFlags.isNewPaymentsAPIEnabled)) func testNewPaymentsAPI() { /* This test only runs if the flag is enabled. */ } ``` | --- ### Specialised Assertions for Clearer Failures Generic boolean checks like `#expect(a == b)` are good, but purpose-built assertions provide far sharper and more actionable failure messages. They tell you not just *that* something failed, but *why*. | Assertion Type | Why It's Better Than a Generic Check | | :--- | :--- | | **Comparing Collections**
Use `#expect(collection:unorderedEquals:)` | A simple `==` check on arrays will fail if the elements are the same but the order is different. This specialized assertion checks for equality while ignoring order, preventing false negatives for tests where order doesn't matter.

**Generic (Brittle):** `#expect(tags == ["ios", "swift"])`
**Specialized (Robust):** `#expect(collection: tags, unorderedEquals: ["swift", "ios"])` | | **Comparing Results**
Use `#expect(result:equals:)` | When testing a `Result` type, a generic check might just tell you that two `Result` instances are not equal. This specialized assertion provides specific diagnostics for whether the failure was in the `.success` or `.failure` case, and exactly how the payloads differed. | | **Floating-Point Accuracy**
Use `accuracy:` overloads (via Swift Numerics or custom helpers) | Floating-point math is inherently imprecise. `#expect(0.1 + 0.2 == 0.3)` will fail. Specialized assertions allow you to specify a tolerance, ensuring tests are robust against minor floating-point inaccuracies.

**Generic (Fails):** `#expect(result == 0.3)`
**Specialized (Passes):** `#expect(result.isApproximatelyEqual(to: 0.3, absoluteTolerance: 0.0001))` | ## **Appendix: Evergreen Testing Principles** These foundational principles pre-date Swift Testing but are 100% applicable. The framework is designed to make adhering to them easier than ever. ### The F.I.R.S.T. Principles | Principle | Meaning | Swift Testing Application | |---|---|---| | **Fast** | Tests must execute in milliseconds. | Lean on default parallelism. Use `.serialized` sparingly and only on suites that absolutely require it. | | **Isolated**| Tests must not depend on each other or external state. | Swift Testing enforces this by creating a new suite instance for every test. Use dependency injection and test doubles to replace external dependencies. | | **Repeatable** | A test must produce the same result every time. | Control all inputs, such as dates and network responses, with mocks and stubs. Reset state in `deinit`. | | **Self-Validating**| The test must automatically report pass or fail without human inspection. | Use `#expect` and `#require`. Never rely on `print()` statements for validation. | | **Timely**| Write tests just before or alongside the production code they verify. | Embrace parameterized tests (`@Test(arguments:)`) to reduce the friction of writing comprehensive test cases. | ### Core Tenets of Great Tests * **Test the Public API, Not the Implementation** Focus on *what* your code does (its behavior), not *how* it does it (its internal details). Testing private methods is a sign that a type may have too many responsibilities and should be broken up. * **One "Act" Per Test** Each test function should verify a single, specific behavior. While you can have multiple assertions (`#expect`) to validate the outcome of that one action, avoid a sequence of multiple, unrelated actions in a single test. * **Avoid Logic in Tests** If you find yourself writing `if`, `for`, or `switch` statements in a test, it's a "code smell." Your test logic is becoming too complex. Extract helper functions or, better yet, simplify the test's scope. * **Name Tests for Clarity** A test's name should describe the behavior it validates. Swift Testing's `@Test("...")` display name is perfect for this. A good format is: `“ under results in ”`. ```swift @Test("Adding an item to an empty cart increases its count to one") func testAddItemToEmptyCart() { /* ... */ } ``` * **Use Descriptive Failure Messages** The `#expect` macro can take an optional string message. Use it to provide context when an assertion fails. It will save you debugging time later. ```swift #expect(cart.itemCount == 1, "Expected item count to be 1 after adding the first item.") ``` * **Eliminate Magic Values** Extract repeated literals (strings, numbers) into clearly named constants. This makes tests more readable and easier to maintain. **Bad:** `#expect(user.accessLevel == 3)` **Good:** ```swift let adminAccessLevel = 3 #expect(user.accessLevel == adminAccessLevel) ```