WWDC.ai

Migrate to Swift Testing

Adopt Swift Testing incrementally alongside XCTest using interoperability modes, migration patterns, parameterized tests, and exit tests.

Watch on Apple Developer

TL;DR

  • Swift Testing can coexist with XCTest in the same test target; keep existing XCTests in place and write new tests with @Test, #expect, and related Swift Testing APIs.
  • Xcode 27 enables test framework interoperability by default so Swift Testing tests can surface XCTest assertion failures, and XCTests can use supported Swift Testing issue APIs.
  • Interoperability modes control cross-framework issue behavior: Limited warns, Complete preserves errors, Strict traps on XCTest cross-framework issues, and None disables reporting temporarily.
  • After migration, use Swift Testing features such as parameterized tests for parallel input combinations and exit tests for code paths that intentionally crash.

Migration approach

The recommended strategy is incremental migration rather than rewriting all existing XCTests. Leave current XCTest cases in place, start writing new tests with Swift Testing, and migrate tests that you touch frequently or that benefit from Swift Testing features.

A test target can contain both XCTest and Swift Testing tests, but Swift Testing tests do not go inside XCTest classes. XCTest remains necessary for UI automation, performance testing APIs, and Objective-C exception testing, where Swift cannot safely handle exceptions.

  • Import the Testing framework and declare tests with the @Test macro.
  • Use raw identifiers with backticks for readable test names containing spaces or punctuation.
  • Use #expect for most assertions; use Issue.record for unconditional failures that replace XCTFail.

Basic Swift Testing test

Shows the core Swift Testing shape: import Testing, mark a function with @Test, and assert behavior with #expect.

import Testing
@testable import DemoApp

@Test
func `Default climate: tropical`() async throws {
    let fruit = Fruit(name: "Coconut")
    #expect(fruit.climate == .tropical)
}

Test framework interoperability

Test framework interoperability lets code from one test framework safely report issues while running inside a test owned by the other framework. This is useful when new Swift Testing tests reuse existing helper functions that still call XCTest APIs, or when shared helpers are migrated to Swift Testing APIs while still being used by XCTests.

A common migration path is to update helper functions from XCTest issue reporting to Swift Testing issue reporting. For helpers that previously forwarded file and line to XCTFail, migrate to SourceLocation and Issue.record.

  • Cross-framework issues from XCTest in Swift Testing tests are handled according to the selected interoperability mode.
  • Cross-framework issues from Swift Testing in XCTests remain errors in all interoperability modes.
  • Supported interoperability includes XCTest assertions, Swift Testing #expect and #require, Swift Testing known-issue APIs for XCTest assertion failures, and Test.cancel for skipping XCTest cases.

XCTest helper before migration

An existing helper wraps XCTFail and can be reused during migration, but may create cross-framework issues when called from Swift Testing tests.

func assertUnique(_ fruits: [Fruit], file: StaticString = #filePath, line: UInt = #line) {
    var uniqueNames = Set<String>()
    for name in fruits.map(\.name) {
        if !uniqueNames.insert(name).inserted {
            XCTFail("Duplicate name: \(name)", file: file, line: line)
        }
    }
}

Swift Testing helper after migration

Replacing XCTFail with Issue.record lets the same helper report failures correctly from both Swift Testing tests and XCTests under interoperability.

import Testing

func assertUnique(_ fruits: [Fruit], sourceLocation: SourceLocation = ...) {
    var uniqueNames = Set<String>()
    for name in fruits.map(\.name) {
        if !uniqueNames.insert(name).inserted {
            Issue.record("Duplicate name: \(name)", sourceLocation: sourceLocation)
        }
    }
}

Interoperability modes and configuration

Interoperability modes determine how cross-framework issues are reported. Test plans created before Xcode 27 inherit Limited mode, while new projects use Complete mode. The mode can be changed in Xcode Test Plan Settings under Test Execution.

Swift Package projects also support interoperability modes. With the Swift 6.4 toolchain, limited mode is enabled by default for packages using older tools versions; updating the package to swift-tools-version 6.4 or newer changes the default behavior to Complete. The mode can also be overridden from the command line.

  • Limited: cross-framework issues from XCTest are warnings, so the Swift Testing test can still pass.
  • Complete: those issues remain errors, making failures harder to miss.
  • Strict: cross-framework issues from XCTest stop the test with a fatal error, helping find places to replace XCTest APIs.
  • None: opts out of interoperability and should only be temporary because it can hide real failures.

Run Swift Package tests in strict mode

Use the SWIFT_TESTING_XCTEST_INTEROP_MODE environment variable with a lowercase mode name to override package test behavior.

SWIFT_TESTING_XCTEST_INTEROP_MODE=strict swift test

Common migration patterns

Several XCTest patterns have direct Swift Testing replacements, but the preferred Swift Testing style often moves intent into the test declaration or into individual assertions.

For skipped tests, Test.cancel can replace XCTSkip-style logic, including in XCTests through interoperability. In new Swift Testing tests, prefer enabled or disabled traits so test availability is visible before the test body runs. For halting after a failed assertion, replace XCTest's continueAfterFailure = false style with #require at the specific point where execution should stop.

  • Use Test.cancel when cancellation must happen from inside the test body.
  • Prefer @Test(.enabled(if:reason)) or disabled traits for Swift Testing enablement rules.
  • Use #expect for non-halting checks and #require for checks that must stop the test on failure.

Skip migration: body cancellation vs. trait

Traits keep test enablement logic at the declaration instead of hiding it inside the test body.

let isFall = false

// Interoperable Swift Testing API
func testSwallowFallMigration() async throws {
    if !isFall {
        try Test.cancel("Wrong season for migration")
    }
    // ...
}

// Prefer this for new Swift Testing tests
@Test(.enabled(if: isFall, "Wrong season for migration"))
func `Swallow fall migration`() async throws {
    // ...
}

Use #require to halt after a failure

#require throws when it fails, stopping the test only at the checks that must be fatal.

func testExample() async throws {
    #expect(Fruit.banana.climate == .temperate)
    try #require(Fruit.banana == Fruit.plantain)
    XCTFail("This is never reached")
}

Swift Testing features worth adopting

Parameterized tests replace loop-based tests with separate generated test cases for each input combination. Swift Testing runs test cases in parallel by default, and each failing argument combination is visible in the Test navigator, which improves both speed and diagnostics.

Exit tests cover code paths that intentionally terminate the process, such as preconditionFailure. Swift Testing runs the exit-test body in a child process, allowing the code to crash without disrupting the rest of the suite, then verifies the process exit condition. Exit tests are supported on macOS, Linux, FreeBSD, and Windows.

  • Use @Test(arguments:) to express repeated test inputs instead of nested loops.
  • Each argument combination becomes an individual test case with clearer failure reporting.
  • Use #expect(processExitsWith:) when the expected behavior is process termination.

Convert nested loops to a parameterized test

The @Test(arguments:) form generates combinations for birds and counts and can run them in parallel.

struct BirdTests {
    @Test(arguments: Aviary.birds, 40...100)
    func `Birds flap wings successfully`(bird: Bird, count: Int) async throws {
        try await bird.flapWings(count: count)
    }
}

Cover a precondition failure with an exit test

The initializer is expected to terminate the child process, and the test passes when the process exits with failure.

extension BirdTests {
    @Test
    func `Bird with empty name crashes`() async throws {
        await #expect(processExitsWith: .failure) {
            _ = Bird(name: "")
        }
    }
}
Unofficial, not associated with Apple. BySuperwall

On this page

Ask AI