Swift Testing shipped with Xcode 16 back in 2024.
Swift Testing was built from the ground up for Swift. That means Swift concurrency is a first-class citizen, test cases run in parallel by default, and the API surface is dramatically smaller than XCTest's forty-plus assertion functions. One macro, #expect, replaces most of them.
If you are still on XCTest, you have probably felt the friction: class inheritance for every test suite, function names that must start with test, assertion messages that tell you what the values were but not where the expression came from. Swift Testing fixes all of this.
That said, you do not need to migrate everything at once, and WWDC 2026 is emphatic about this.
The Migration Strategy: Small Chunks, No Big Bang
The session opens with something refreshing: permission to be slow about this.
The recommended approach is to leave your existing XCTests where they are and start using Swift Testing only for new tests. Both frameworks can coexist in the same target and even the same file. You do not need a separate test target, and you do not need a migration sprint.
The one rule: Swift Testing tests cannot live inside XCTestCase subclasses. Everything else is fair game.
Raw Identifiers for Readable Test Names
One small quality-of-life improvement worth knowing about from the start: Swift supports raw identifiers using backticks, and Swift Testing takes full advantage of this.
import Testing
@testable import DemoApp
@Test func `Default climate: tropical`() async throws {
let fruit = Fruit(name: "Coconut")
#expect(fruit.climate == .tropical)
}
No more testDefaultClimateTropical or dealing with camelCase names in test output. The test name is the test name.
Interoperability: The Key to Reusing Your Helper Code
This is the main new story in WWDC 2026 and the feature that makes incremental migration actually work.
The problem: you have test helper functions that wrap XCTFail. You want to call them from new Swift Testing tests. Previously, this was messy. Now, it works by design.
Interoperability is a feature that lets you safely call API from one test framework inside a test written in the other. So a Swift Testing test can call a helper that internally uses XCTFail, and XCTest tests can use Swift Testing's Issue.record and expectation macros.
Three Interoperability Modes
The session introduces three modes, and understanding them is important for knowing what level of strictness you want:
Limited mode -- Cross-framework issues from XCTest become warnings, not errors. Tests still pass. This is the default for test plans created before Xcode 27.
Complete mode -- Those same warnings become errors. The test will fail. This is the default for new projects in Xcode 27.
Strict mode -- Cross-framework issues from XCTest cause a fatal error and stop the test immediately at the point of the bad call. This is useful when you want to systematically find every place you need to replace XCTest API.
You can change modes in your Test Plan settings under "Test Execution," or for Swift Package projects using an environment variable:
SWIFT_TESTING_XCTEST_INTEROP_MODE=strict swift test
For SPM projects, complete mode requires bumping to swift-tools-version: 6.4 or newer.
Migrating a Helper Function
Here is a typical helper before migration:
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)
}
}
}
After migrating to Swift Testing:
import Testing
func assertUnique(_ fruits: [Fruit], sourceLocation: SourceLocation = #sourceLocation) {
var uniqueNames = Set<String>()
for name in fruits.map(\.name) {
if !uniqueNames.insert(name).inserted {
Issue.record("Duplicate name: \(name)", sourceLocation: sourceLocation)
}
}
}
XCTFail becomes Issue.record. The file and line parameters become a single SourceLocation parameter. The helper can now be called cleanly from both Swift Testing tests and existing XCTests.
Common Migration Patterns
The session walks through two patterns that come up in almost every migration.
Skipping Tests
XCTest uses XCTSkipIf. In Swift Testing, the direct replacement is Test.cancel, but the preferred approach is a trait:
let isFall = false
// XCTest
func testSwallowFallMigration() async throws {
try XCTSkipIf(!isFall, "Wrong season for migration")
}
// Swift Testing via Test.cancel (works but not ideal)
func testSwallowFallMigration() async throws {
if !isFall {
try Test.cancel("Wrong season for migration")
}
}
// Preferred: use a trait
@Test(.enabled(if: isFall, "Wrong season for migration"))
func `Swallow fall migration`() async throws {
// ...
}
Moving the condition into a trait keeps the test body clean and makes the enablement logic visible at a glance.
Halting After Failures
In XCTest, you set continueAfterFailure = false to stop on the first failure. In Swift Testing, you use #require instead of #expect for the assertions where failure should halt the test:
func testExample() async throws {
#expect(Fruit.banana.climate == .temperate)
// If this fails, the test stops here
try #require(Fruit.banana == Fruit.plantain)
// This line is only reached if #require passed
}
The benefit over XCTest's approach: you get fine-grained control over which expectations are fatal and which are not, rather than a single global flag.
What You Unlock After Migrating
The second half of the session is about capabilities that simply do not exist in XCTest.
Parameterized Tests
This is one of the most impactful changes for test suites that have grown unwieldy with repetitive test methods.
Before, you might write a nested loop inside a single test:
@Test func `Birds flap wings successfully`() async throws {
for bird in Aviary.birds {
for count in (40...100) {
try await bird.flapWings(count: count)
}
}
}
The problem: when it fails, you do not know which bird or which count triggered the failure. The whole loop is one test case.
After converting to a parameterized test:
@Test(arguments: Aviary.birds, 40...100)
func `Birds flap wings successfully`(bird: Bird, count: Int) async throws {
try await bird.flapWings(count: count)
}
Swift Testing generates a separate test case for every combination of bird and count. All cases run in parallel. When something fails, the Test navigator shows you exactly which inputs caused the failure. In the session's demo, the refactored test also finished significantly faster because of parallel execution.
Exit Tests
Exit tests let you write coverage for code that is expected to crash -- preconditionFailure, fatalError, and similar calls that have historically been impossible to test without crashing your entire test process.
Given this code in a Bird initializer:
if name.isEmpty {
preconditionFailure("Bird name cannot be empty")
}
You can now write:
@Test func `Bird with empty name crashes`() async throws {
await #expect(processExitsWith: .failure) {
_ = Bird(name: "")
}
}
Swift Testing runs the body of the exit test in a child process. If that process exits with the expected condition, the test passes. The crash is isolated, so it cannot affect any other test. Exit tests are supported on macOS, Linux, FreeBSD, and Windows.
This finally gives you a way to get code coverage on defensive guards that were previously invisible to your test suite.
What Stays in XCTest
The session is clear about what not to migrate:
- UI automation tests using
XCUIApplication - Performance tests using
XCTMetric - Tests that catch Objective-C exceptions (only Objective-C code can handle these safely)
For everything else, Swift Testing is the better home.
Xcode's Migration Assistance
One practical note from the session: Xcode 27's Coding Assistant is aware of the migration documentation and can help formulate a strategy, review your work, and automate parts of the migration. If you are staring at a large test suite and not sure where to start, that is worth exploring.
The Summary
The path forward is clear and low-risk:
- Keep your existing XCTests where they are. Do not touch them until you are ready.
- Write all new tests using Swift Testing.
- Enable interoperability and start with limited mode. Gradually move to complete or strict as you migrate individual helpers.
- When you update a helper, replace
XCTFailwithIssue.recordand update the source location parameter. - Look for nested loops in your tests -- those are candidates for parameterized tests.
- Add exit tests anywhere you have
preconditionFailureorfatalErrorwith no coverage.
The migration is not a one-time event. It is a gradual shift that Xcode 27 is explicitly designed to support.
Top comments (0)