A hands-on, comprehensive guide for migrating from XCTest to Swift Testing and mastering the new framework. This playbook integrates the latest patterns and best practices from WWDC 2024 and official Apple documentation to make your tests more powerful, expressive, and maintainable.
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. It leverages modern Swift features like macros and concurrency. |
| Keep XCTest Targets | Incremental Migration is Key. You can have XCTest and Swift Testing tests in the same target, allowing you to migrate file-by-file without breaking CI. |
| Enable Parallel Execution | In your Test Plan, ensure "Use parallel execution" is enabled. Swift Testing runs tests in parallel by default, which speeds up test runs and helps surface hidden state dependencies. |
- Ensure all developer machines and CI runners are on macOS 15+ and Xcode 16+.
- For projects supporting Linux/Windows, add the
swift-testingSPM package. It's not needed for Apple platforms. - In your primary test plan, confirm that “Use parallel execution” is enabled.
Replace the entire XCTAssert family with two powerful, expressive macros. They accept regular Swift expressions, eliminating the need for dozens of specialized XCTAssert functions.
| Macro | Use Case & Behavior |
|---|---|
#expect(expression) |
Soft Check. Use for most validations. If the expression is false, the issue is recorded, but the test function continues executing. This allows you to find multiple failures in a single run. |
#require(expression) |
Hard Check. Use for critical preconditions (e.g., unwrapping an optional). If the expression is false or throws, the test is immediately aborted. This prevents cascading failures from an invalid state. |
#require is the new, safer replacement for XCTUnwrap. It not only checks for nil but also unwraps the value for subsequent use.
// XCTest
let user = try XCTUnwrap(await fetchUser())
XCTAssertEqual(user.age, 37)
// Swift Testing
let user = try #require(await fetchUser())
#expect(user.age == 37)- Run
grep -R "XCTAssert" .to find all legacy assertions. - Convert
XCTUnwrapcalls totry #require(). - Convert most
XCTAssertcalls to#expect(). Use#require()only for preconditions. - Group related checks with
#expectAll { ... }to ensure all are evaluated and reported together.
Swift Testing replaces setUpWithError and tearDownWithError 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(), setUp() |
The initializer for your suite. Put all setup code here. It can be async and throws. |
deinit |
tearDownWithError(), tearDown() |
The deinitializer. Put cleanup code here. It runs automatically after each test. Note: deinit is only available on class or actor suite types, not structs. This is a common reason to choose a class for your suite. |
| Instance Properties | var sut: MyType! |
Stored properties on the suite (let sut: MyType). They are initialized in init() for each test run. |
// XCTest
final class DatabaseServiceXCTests: XCTestCase {
var sut: DatabaseService!
var tempDirectory: URL!
override func setUpWithError() throws {
try super.setUpWithError()
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)
}
override func tearDownWithError() throws {
try FileManager.default.removeItem(at: tempDirectory)
self.sut = nil
self.tempDirectory = nil
try super.tearDownWithError()
}
func testSavingUser() throws {
let user = User(id: "user-1", name: "Alex")
try sut.save(user)
let loadedUser = try sut.loadUser(id: "user-1")
XCTAssertNotNil(loadedUser)
}
}
// Swift Testing (using a class to get deinit behavior)
@Suite final class DatabaseServiceTests {
let sut: DatabaseService
let tempDirectory: URL
init() throws {
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 {
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)
}
}- Convert test classes from
XCTestCasetostructs (preferred) orfinal classes ifdeinitis needed. - Move
setUpWithErrorlogic into the suite'sinit(). - Move
tearDownWithErrorlogic into the suite'sdeinit. - Define the SUT and its dependencies as non-optional
letproperties, initialized ininit().
Go beyond do/catch with a dedicated, expressive API for validating thrown errors.
| Overload | Replaces... | Example & Use Case |
|---|---|---|
#expect(throws: Error.self) |
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(performing:throws:) |
do/catch with switch |
Payload Introspection. The ultimate tool for errors with associated values. It gives you a closure to inspect the thrown error. |
#expect(throws: Never.self) |
XCTAssertNoThrow |
Explicitly asserts that a function does not throw. Ideal for happy-path tests. |
// XCTest
func testBrewingThrows() {
XCTAssertThrowsError(try coffeeMaker.brew(), "Expected brewing to fail") { error in
XCTAssertEqual(error as? BrewingError, .outOfBeans)
}
}
// Swift Testing
@Test func brewingThrowsExpectedError() {
#expect(throws: BrewingError.outOfBeans) {
try coffeeMaker.brew()
}
}Run a single test function with multiple argument sets to maximize coverage with minimal code. This is a direct replacement for repetitive test functions and is superior to a for-in loop because each argument set runs as an independent, parallelizable test.
| Pattern | How to Use It & When |
|---|---|
| Single Collection | @Test(arguments: [0, 100, -40]) The simplest form. Pass a collection of inputs. |
| Zipped Collections | @Test(arguments: zip(inputs, expectedOutputs)) The most common and powerful pattern. Use zip to pair inputs and expected outputs. |
| Multiple Collections | @Test(arguments: ["USD", "EUR"],) |
// XCTest
final class MathXCTests: XCTestCase {
func testIsEven() {
XCTAssertTrue(isEven(2))
}
func testIsOdd() {
XCTAssertFalse(isEven(3))
}
func testIsZeroEven() {
XCTAssertTrue(isEven(0))
}
}
// Swift Testing
@Suite struct MathTests {
@Test("Number evenness check", arguments: [
(2, true),
(3, false),
(0, true),
(-4, true)
])
func isEven(number: Int, expected: Bool) {
#expect(isEven(number) == expected)
}
}Dynamically control which tests run based on feature flags, environment, or known issues. This replaces XCTest's XCTSkipUnless and manual commenting-out of tests.
| Trait | Replaces... | What It Does & How to Use It |
|---|---|---|
.disabled("Reason") |
Commenting out a test | Unconditionally skips a test. The test is not run, but it is still compiled. Always provide a descriptive reason for CI visibility (e.g., "Flaky on CI, see FB12345"). |
.enabled(if: condition) |
XCTSkipUnless |
Conditionally runs a test. The test only runs if the boolean condition is true. This is perfect for tests tied to feature flags or specific environments. |
@available(...) |
Runtime #available check |
OS Version-Specific Tests. Apply this attribute directly to the test function. It's better than a runtime check because it allows the test runner to know the test is skipped for platform reasons. |
// XCTest
func testNewFeature() throws {
try XCTSkipUnless(FeatureFlags.isNewAPIEnabled, "New API feature flag is not enabled.")
// ... test code
}
// Swift Testing
@Test("New feature test", .enabled(if: FeatureFlags.isNewAPIEnabled, "New API feature flag not enabled"))
func testNewFeature() {
// ... test code
}While #expect(a == b) works, purpose-built assertions provide sharper, more actionable failure messages by explaining why something failed, not just that it failed.
| Assertion Type | Why It's Better Than a Generic Check |
|---|---|
| Comparing Collections (Unordered) Use #expect(collection:unorderedEquals:) |
A simple == check on arrays fails if 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. Brittle: #expect(tags == ["ios", "swift"]) Robust: #expect(collection: tags, unorderedEquals: ["swift", "ios"]) |
| Floating-Point Accuracy Use accuracy: parameters. |
Floating-point math is 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. Fails: #expect(result == 0.3) Passes: #expect(result, toEqual: 0.3, within: 0.0001) |
Use suites and tags to manage large and complex test bases.
A @Suite groups related tests and can be nested for a clear hierarchy. Traits applied to a suite are inherited by all tests and nested suites within it.
Tags associate tests with common characteristics (e.g., .network, .ui, .regression) regardless of their suite. This is invaluable for filtering.
- Define Tags in a Central File:
// /Tests/Support/TestTags.swift import Testing extension Tag { @Tag static var fast: Self @Tag static var regression: Self @Tag static var flaky: Self }
- Apply Tags & Filter:
// Apply to a test or suite @Test("Username validation", .tags(.fast, .regression)) func testUsername() { /* ... */ } // Run from CLI // swift test --filter-tag fast // Filter in Xcode Test Plan // Add "fast" to the "Include" field or "flaky" to the "Exclude" field.
Swift Testing's native async/await support simplifies asynchronous code significantly compared to XCTestExpectation.
- Async Tests: Simply mark your test function
asyncand useawait. confirmation: ReplacesXCTestExpectationfor testing callbacks, notifications, or delegate methods.fulfillment(of:timeout:): Replaceswait(for:timeout:). This is the global function youawaitto pause the test until your confirmations are fulfilled or a timeout is reached.
// XCTest
func testDataDownload() {
let expectation = self.expectation(description: "Download should complete")
let downloader = Downloader()
downloader.onComplete = { data in
XCTAssertFalse(data.isEmpty)
expectation.fulfill()
}
downloader.start()
waitForExpectations(timeout: 5.0)
}
// Swift Testing
@Test("Data downloads successfully")
async func testDataDownload() async throws {
let downloadCompleted = confirmation("Download should complete")
let downloader = Downloader()
downloader.onComplete = { data in
#expect(!data.isEmpty)
await downloadCompleted()
}
downloader.start()
try await fulfillment(of: [downloadCompleted], timeout: .seconds(5))
}.serialized: Apply this trait to a@Testor@Suiteto force its contents to run serially. Use this as a temporary measure for legacy tests that are not thread-safe or have hidden state dependencies..timeLimit: A safety net to prevent hung tests from stalling CI.
| Feature | What it Does & How to Use It |
|---|---|
withKnownIssue |
Marks a test as an Expected Failure. It's better than .disabled for known bugs. The test still runs but won't fail the suite. Crucially, if the underlying bug gets fixed and the test passes, withKnownIssue will fail, alerting you to remove it. |
CustomTestStringConvertible |
Provides custom, readable descriptions for your types in test failure logs. Conform your key models to this protocol to make debugging much easier. |
.bug("JIRA-123") Trait |
Associates a test directly with a ticket in your issue tracker. This adds invaluable context to test reports in Xcode and Xcode Cloud. |
Test.current |
A static property (Test.current) that gives you runtime access to the current test's metadata, such as its name, tags, and source location. Useful for advanced custom logging. |
#expectAll { ... } |
Groups multiple assertions. If any assertion inside the block fails, they are all reported together, but execution continues past the block. |
Swift Testing and XCTest can coexist in the same target, enabling an incremental migration.
| Feature | XCTest | Swift Testing |
|---|---|---|
| Test Discovery | Method name must start with test... |
@Test attribute on any function or method. |
| Suite Type | class MyTests: XCTestCase |
struct MyTests (preferred), class, or actor. No inheritance needed. |
| Assertions | XCTAssert...() family of functions |
#expect() and #require() macros with Swift expressions. |
| Error Unwrapping | try XCTUnwrap(...) |
try #require(...) |
| Setup/Teardown | setUpWithError(), tearDownWithError() |
init(), deinit (on classes/actors) |
| Asynchronous Wait | XCTestExpectation |
confirmation() and await fulfillment(of:timeout:) |
| Parallelism | Opt-in, multi-process | Opt-out, in-process via Swift Concurrency. Runs in parallel by default. |
Continue using XCTest for the following, as they are not currently supported by Swift Testing:
- UI Automation Tests (using
XCUIApplication) - Performance Tests (using
XCTMetricandmeasure { ... }) - Tests written in Objective-C
| XCTest Assertion | Swift Testing Equivalent | Notes |
|---|---|---|
XCTAssert(expr) |
#expect(expr) |
Direct replacement. |
XCTAssertEqual(a, b) |
#expect(a == b) |
Use standard operators. |
XCTAssertNotEqual(a, b) |
#expect(a != b) |
Use standard operators. |
XCTAssertNil(a) |
#expect(a == nil) |
Direct check for nil. |
XCTAssertNotNil(a) |
#expect(a != nil) |
Direct check for not-nil. |
XCTAssertTrue(a) |
#expect(a) |
No change needed if a is a Bool. |
XCTAssertFalse(a) |
#expect(!a) |
Use the ! operator. |
XCTAssertGreaterThan(a, b) |
#expect(a > b) |
Use standard operators. |
These foundational principles are framework-agnostic, and Swift Testing is designed to make adhering to them easier than ever.
| Principle | Meaning | Swift Testing Application |
|---|---|---|
| Fast | Tests must execute in milliseconds. | Lean on default parallelism. Use .serialized sparingly. |
| Isolated | Tests must not depend on each other. | Swift Testing enforces this by creating a new suite instance for every test. Random execution order helps surface violations. |
| Repeatable | A test must produce the same result every time. | Control all inputs (dates, network responses) with mocks/stubs. Reset state in init/deinit. |
| Self-Validating | The test must automatically report pass or fail. | Use #expect and #require. Never rely on print() for validation. |
| Timely | Write tests alongside the production code. | Use parameterized tests (@Test(arguments:)) to easily cover edge cases as you write code. |
See also my blog post: https://steipete.me/posts/2025/migrating-700-tests-to-swift-testing