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.
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. |
- Ensure all developer machines and CI runners are on macOS 15+ and Xcode 16.3+.
- For any projects supporting Linux/Windows, add the
swift-testingSPM package. It's not needed for Apple platforms. - In your primary test plan, flip the switch to “Use parallel execution”.
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. |
#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)- Run
grep -R "XCTAssert" .on your project to find all legacy assertions. - Convert
XCTUnwrapcalls totry #require(). - Convert most
XCTAssertcalls to#expect(). Use#require()for preconditions that would make the rest of the test invalid.
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. |
@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.
}
}- Convert test classes inheriting from
XCTestCasetostructs orfinal classes. - Move logic from
setUpWithErrorinto the suite'sinit(). - Move cleanup logic from
tearDownWithErrorinto the suite'sdeinit. - Define the SUT and its dependencies as
letproperties on the suite, initialized insideinit().
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. |
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). |
Use suites and tags to manage large and complex test bases.
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 associate tests that share common characteristics, regardless of their suite.
- Define Tags in a Central File:
// /Tests/Support/TestTags.swift import Testing extension Tag { @Tag static var fast: Self @Tag static var regression: Self }
- Apply Tags & Filter:
@Test("Username validation", .tags(.fast, .regression)) // Apply // swift test --filter-tag fast // Run from CLI
Simply mark your test function async and use await.
@Test("User profile downloads correctly")
async func testProfileDownload() async throws { /* ... */ }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)
// ...
}Apply the .serialized trait to any @Test or @Suite to opt out of concurrent execution for tests that are not thread-safe.
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 { /* ... */ }
}| 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. |
- 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.
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)) } |
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) } |
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. */ } |
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)) |
These foundational principles pre-date Swift Testing but are 100% applicable. The framework 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 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. |
-
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, orswitchstatements 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
#expectmacro 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)
See also my blog post: https://steipete.me/posts/2025/migrating-700-tests-to-swift-testing