Skip to content

Instantly share code, notes, and snippets.

@steipete
Last active October 25, 2025 17:37
Show Gist options
  • Save steipete/84a5952c22e1ff9b6fe274ab079e3a95 to your computer and use it in GitHub Desktop.
Save steipete/84a5952c22e1ff9b6fe274ab079e3a95 to your computer and use it in GitHub Desktop.
The Ultimate Swift Testing Playbook (feed it your agents for better tests!)

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.

// 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

@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 structs or final classes.
  • 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.

@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:
    // /Tests/Support/TestTags.swift
    import Testing
    extension Tag {
        @Tag static var fast: Self
        @Tag static var regression: Self
    }
  2. Apply Tags & Filter:
    @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.

@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.

@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)).
// 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: “<Behavior> under <Condition> results in <Expected Outcome>”.

    @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.

    #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:

    let adminAccessLevel = 3
    #expect(user.accessLevel == adminAccessLevel)
@steipete
Copy link
Author

steipete commented Jun 7, 2025

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment