-
Migrate to Swift Testing
Learn how to fearlessly adopt Swift Testing alongside your XCTests using test framework interoperability. Discover best practices and patterns for incrementally introducing advanced testing features that accelerate development and increase coverage.
Chapters
- 0:07 - Introduction
- 1:08 - Swift Testing basics
- 2:50 - Migration strategy
- 5:48 - Test framework interoperability
- 7:43 - Interoperability modes
- 13:02 - Common migration patterns
- 15:34 - Parameterized tests
- 18:02 - Exit tests
- 20:04 - Next steps
Resources
Related Videos
WWDC26
WWDC24
-
Search this video…
Hi! My name is Jerry, and I'm an engineer on the Swift Testing team. Today, I'll share how it's easier than ever to migrate from XCTest to Swift Testing.
We introduced Swift Testing in Xcode 16. It's a modern testing library with an expressive and concise interface for writing tests. It also fits right in with the Swift ecosystem. For example, it was built with Swift concurrency in mind, so it runs test cases in parallel and delivers results super quickly. I'll begin with a review of some Swift Testing building blocks and compare those to similar concepts from XCTest. Next, I'll add Swift Testing to an XCTest project. Along the way, I'll demonstrate how to reuse existing code with the test framework interoperability feature, and even if you don't use XCTest, please stick around! I'll share how to use Swift Testing features to boldly go where no XCTest has gone before! Let's get started with the basics of Swift Testing.
I begin a test file by importing the Swift Testing framework along with a testable import to access internal types. Then, I create a new function and annotate it with the @Test macro to declare it as a test.
Swift supports raw identifiers, which are indicated by these backticks. They let me mix in spaces and punctuation to help me read longer test names. In the body of the test, I use the #expect macro to create this expectation that fruits have a tropical climate. That's all it takes to create a new test! The @Test and #expect macros are the core building blocks for most tests. If you're totally new to Swift Testing, or you just want to jog your memory, "Meet Swift Testing" is a fantastic resource. It covers additional building blocks and testing workflows.
The #expect macro is super flexible and replaces many XCTest assertions. To replace XCTFail, which is an unconditional failure, use Issue.record instead.
With Swift Testing, you'll enjoy using less code to write more powerful and expressive tests. However, keep using XCTest for a few scenarios. UI automation and performance testing APIs are only available in XCTest, and when testing code that throws Objective-C exceptions, you have to use XCTests also written in Objective-C. That's because Swift code, including XCTests in Swift, can't safely handle these exceptions. All right, I know you're eager to write some tests now! So, I'll demonstrate how to migrate fearlessly to Swift Testing.
I'll explain my strategy for migrating test code in small chunks.
Next, I'll introduce test framework interoperability, a powerful tool that will allow me to reuse existing test code.
And to help your own migration go smoothly, I'll share a few common patterns to follow. Ok. Let's talk migration strategy.
When I modify old tests, I introduce risk, even for small changes. So, I'm going to leave most XCTests in their place. When I'm ready, I'll modify tests a few at a time and focus on the ones that I update most often. My existing test targets can include tests from both frameworks. So, I can already use Swift Testing for my new tests, although they can't go inside XCTest classes. Ok! Now that I have a migration strategy, I'm ready to put it to the test. I made an app to train birds in my neighbourhood to do fruit delivery. Just like how birds migrate with the changing of the season, I think it's the season for me to also migrate to Swift Testing.
Here's my XCTest suite for my Fruit data structure. I recently added climate information to the fruit but haven't tested it yet. I think I want to add some new tests using Swift Testing, and I can do that without even leaving this file! First, I'll import the Swift Testing framework.
Then, I'll add my test at the end of this file.
By the way, I love that Swift Testing lets me put this outside of a test suite. I can always create a parent suite later, once I have more tests. In the Test navigator, I'm noticing the new test is already included. Now, I'll go to the Product menu and select Test.
Great! My new test ran successfully.
Some of my tests require more setup. This test checks in an array of fruit has all unique names. I originally wrote this test with multiple lines of code to set up the assertion.
When I ran the test later, I found it difficult to understand why it failed. This test was trying to tell a story, but it got lost in all this code. So, I extracted this multi-step assertion into a helper function. Now, it's clearer what I'm testing and how I'm testing it. As a finishing touch, I provided file and line number parameters and passed them to XCTFail.
Xcode now attributes the failure to the line where I call the helper.
I want to create new tests with Swift Testing which also call this helper, and, ultimately, XCTFail. However, XCTFail isn't part of Swift Testing. This is where test framework interoperability can help! It's a feature that lets you safely call API from one test framework, while in the body of a test, from the other.
When considering interoperability, there's two directions to think about. You can call XCTest API which reports an issue in a Swift Testing test. This is what happens when I reuse my assertUnique helper which wraps XCTFail. In the other direction, you can call Swift Testing API which reports an issue in an XCTest. I'll explain this one later.
In either case, you end up creating a cross-framework issue, with the issue-reporting API and the test it's called in belong to different frameworks. Xcode enables interoperability by default to handle these issues. Let me show you how.
I call the assertUnique helper in testUniqueFruitNames. The helper reports failures using XCTFail. I've created another Swift Testing test with the same body. This helper should report the same failure, but in this test, it's a cross-framework issue.
Let's compare the result after running this test.
Hmm, I'm noticing the test passes, but I actually thought it would fail, like the XCTest above. A test failure can be a good thing, because it means my helper is working to catch bugs. There's some messages here, though. I'll click to view them.
Interoperability created two warnings, indicated by the purple triangles. Because these aren't errors, the test still passes. The first warning tells me that Lychee is a duplicate name, and the second warning instructs me to replace XCTFail with Issue.record from Swift Testing.
Interoperability comes with different modes for handling cross-framework issues. I've just explained the limited mode. In this mode, cross-framework issues from XCTest are warnings. Test plans created before Xcode 27 inherit limited mode. For new projects, Xcode uses the complete mode. In this mode, those same issues stay as errors. Change the mode at any time using your Test Plan Settings. You can find it under the Test Execution section. Let's find out how the complete mode changes my test results. I'll go edit my test plan.
Here, I'll filter on interoperability, and I'll change that mode to Complete.
Now I'll go back to my "Unique fruit names" test, and run it again.
Now, my test fails. Let's check the messages.
The complete mode preserves the error created by XCTFail. It also keeps the warning that instructs me to migrate. Think of complete mode as a step above limited mode. It elevates cross-framework issues from warnings to errors, so you're less likely to miss them. One more step above complete mode is strict mode. For cross-framework issues from XCTest, strict mode stops the test in its tracks with a fatal error. This helps you find places to replace XCTest API with Swift Testing API. I've already updated the mode to strict. Now, I'll run my test again.
Whoa! The test stopped exactly where I call XCTFail in my helper function! Let's check out this message.
Once again, I have instructions to replace XCTFail, but I have to keep in mind I still have XCTests that call this helper. That's why interoperability also supports cross-framework issues from Swift Testing. In all modes, those issues remain errors. So, you're empowered to call Swift Testing API within tests from both frameworks. Replacing XCTFail just takes a few steps. I'll start by replacing the XCTest import with a Swift Testing import.
Then, replace XCTFail with Issue.record, and sourceLocation replaces the original file and line parameters.
After updating my helper, I'll run all my test cases again. I'll use CMD+U, which is the shortcut for product test.
With the updated helper, my new test and my original XCTest both fail as expected. Thanks to test framework interoperability, I replaced XCTest API with Swift Testing API without changing the meaning of my tests. There's one more mode I haven't introduced yet. You can set the mode to none to opt-out of interoperability. After opting-out, Xcode won't report cross-framework issues in either direction, but, those issues can highlight bugs in your app. You won't catch those bugs if you disable interoperability. So, if you have to use this mode, only use it temporarily. Instead, prefer complete or strict mode. Complete mode maintains cross-framework issues as errors, and it's a worthy steup up from the limited mode. The strict mode, is, well, strict! It's a great fit if you want to completely prevent cross-framework issues from XCTest. You can also use interoperability and its different modes in Swift Package projects. Here's an example of a project created with swift-tools-version: 6.3, and it has a test that produces a cross-framework issue from XCTest. By default, the Swift 6.4 toolchain enables limited mode. When I run this project with the swift test command, I notice that it reports the cross-framework issue as a warning.
To use complete mode, update your package to swift-tools-version 6.4 or newer.
After bumping the tools version, the earlier issue is now an error.
Override the default mode at any time using an environment variable: SWIFT_TESTING_XCTEST_INTEROP_MODE For the value, provide the name of the mode in lowercase.
Finally, interoperability supports a limited set of APIs from both testing frameworks. I just demonstrated the XCTFail and Issue.record API. In XCTest, it also supports all other test assertions, and in Swift Testing, it supports both expectation macros: #expect and #require. The known issue API in Swift Testing can mark XCTest assertion failures as known, and Test.cancel can skip test cases in XCTest.
On your own migration journey, you might encounter some common patterns. I'll share some tips for how to address those.
The first pattern is skipping tests. In XCTest, you skip tests using the XCTSkip API.
To replace it with Swift Testing API, use Test.cancel. If you're writing new tests in Swift Testing, Test.cancel will work there as well. But, Swift Testing has traits, which are annotations you can attach to test functions and suites. Move test enablement logic out of the test body with the enabled or disabled trait.
Another common pattern is halting on failure. In XCTest, you assign the continueAfterFailure property to false. This halts tests on their first failed assertion. To replace with Swift Testing API, use the #require macro. It throws an error upon failure, halting the test, and you don't need to set continueAfterFailure anymore. With Swift Testing, you also get to choose which expectations halt the test and which don't.
For more information, check out "Migrating a test from XCTest" in the developer documentation. It covers many more scenarios and will be a great reference in your migration journey. Xcode's Coding Assistant is also aware of this documentation. It can help formulate a migration strategy or review your work. It even has a skill to automate parts of your migration. Wow, we covered a lot! So, in summary, here's how you can migrate fearlessly to Swift Testing.
Don't feel pressured to modify your XCTests until you're ready. Focus on writing new tests using Swift Testing. By relying on interoperability, you can even reuse your helper code which wraps XCTest API.
In the process, you'll turn up cross-framework issues from XCTest. Interoperability allows you to replace XCTest API with Swift Testing API to address these, and Xcode 27 enables interoperability by default. Make sure you handle all future cross-framework issues by upgrading to the complete or strict mode.
Now, it's time to turn the spotlight on Swift Testing.
After you migrate to Swift Testing, you get access to new tools to supercharge your tests. Let's start with parameterized tests.
These are tests that repeat with different arguments. Each argument becomes a separate test case. All Swift Testing tests, including parameterized test cases, run in parallel by default. This can be faster than running serially. Check out this example.
I've migrated my project's bird test to Swift Testing. This checks that every bird can flap its wings from forty to a hundred times. I can't write a separate test for each of those combinations though, there's just too many of them. In the body of my original XCTest, I used a nested loop to generate all the combinations. I'll go ahead and run the test.
A few things stuck out to me. The test took a while to finish, probably because there's lots of combinations. Also, the test fails, but I don't even know which bird failed.
If I were still using XCTest, I'd have to catch these errors or run the test with a debugger attached. I have a better idea. Let's make this a parameterised test. I'll begin by removing these loops from the test body, and instead, I'll define bird and count as test function parameters.
I'll provide the input for birds and counts as arguments in the test macro.
Swift Testing will pair each bird from the first argument and each count from the second. I'll run the test again.
Hey! This time, the test finished almost instantly, because Swift Testing ran all my combinations in parallel. In the Test navigator, my parameterised test now has a disclosure arrow to its left. I'll click it to show all the combinations I'm testing.
Wow, that's a lot of test cases. Ok, I still have to find the failing combinations. In the Test navigator, I'll filter the failing test results.
Ah, there it is! The swallow can't flap its wings less than 43 times. Using a parameterised test supercharged my test execution. Not only did I get results faster, I could clearly tell which test inputs failed.
Next, I want to show how to supercharge test coverage with exit tests. Note that exit tests are only supported on macOS, Linux, FreeBSD, and Windows. I'm looking for code that needs test coverage, and I found something in my Bird initialiser.
If I get an empty name for new Birds, I halt the program with a precondition failure.
I can verify if I've already tested this by going to the "Editor" menu and showing "Code Coverage." The coverage annotation shows the precondition in red, which means that none of my tests run this code. An exit test is the perfect tool to add this coverage.
You define an exit test with the #expect macro. Provide an expected process exit condition, along with the body of the exit test. When you start the test, Swift Testing runs the exit test body in a child process. Because that code is isolated, it can crash without disrupting other tests. The exit test waits for the child process to finish and checks the exit status to determine test success or failure. I can add a new test in this extension of my test suite. I'll use the #expect macro to create an exit test and specify the process should exit with failure.
Inside the body of the exit test, I'll create a Bird with an empty name. That should crash, but it'll be isolated, because Swift Testing will run this code in a separate process. Now, I'll run all my tests.
Great, my exit test passes! Let's check the coverage one more time. I'll right click and select "Jump to Definition" on the Bird initializer. Now that we're back, let's examine the coverage again. The coverage annotation now highlights the precondition in green, which means it's now tested! That was a quick preview of two ways to supercharge your tests, and if you have ideas for how Swift Testing can improve, I encourage you to contribute! That's because Swift Testing is open source. It's part of the SwiftLang organization on GitHub. It's available on more platforms than ever before, with full support starting this year for FreeBSD.
The Testing Workgroup governs the project and runs regular meetings that any Swift community member can attend. New features are guided by Swift Evolution. In fact, interoperability was one of those! Share your opinions and ideas by joining us on the Swift Forums. Now, it's your turn to make like a bird, and migrate to Swift Testing! Interoperability in Xcode 27 makes this transition easier than ever. Try it out in your project and explore its different modes. Along the way, you can adopt powerful tools, such as parameterized tests and exit tests. To, go further, check out "Go further with Swift Testing" from WWDC 2024. Have fun renovating your tests!
-
-
1:12 - Name a test using a raw identifier
import Testing @testable import DemoApp @Test func `Default climate: tropical`() async throws { let fruit = Fruit(name: "Coconut") #expect(fruit.climate == .tropical) } -
5:03 - Wrap XCTFail in a test helper function
func testUniqueFruitNames() async throws { assertUnique(Market.fruits + [Fruit.lychee]) } // TestHelpers.swift 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) } } } -
10:12 - Replace XCTFail with Issue.record in the test helper
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) } } } -
12:15 - Run Swift Package tests with the strict interoperability mode from Terminal
> SWIFT_TESTING_XCTEST_INTEROP_MODE=strict swift test -
13:10 - Common migration: skipping tests
let isFall = false // XCTest func testSwallowFallMigration() async throws { try XCTSkipIf(!isFall, "Wrong season for migration") // ... } // Test.cancel interoperability from Swift Testing func testSwallowFallMigration() async throws { if !isFall { try Test.cancel("Wrong season for migration") } // ... } // ✅ Prefer test trait in Swift Testing @Test(.enabled(if: isFall, "Wrong season for migration")) func `Swallow fall migration`() async throws { // ... } -
13:41 - Common migration: halting after test failures
func testExample() async throws { #expect(Fruit.banana.climate == .temperate) try #require(Fruit.banana == Fruit.plantain) XCTFail("This is never reached") } -
15:57 - Example of nested loops which can be converted into a parameterized @Test function
struct BirdTests { @Test func `Birds flap wings successfully`() async throws { for bird in Aviary.birds { for count in (40...100) { try await bird.flapWings(count: count) } } } } -
16:47 - Refactor nested loops into a parameterized @Test function
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) } } -
18:21 - Precondition check on empty input name in an initializer
// In `Bird.init(...)` if name.isEmpty { preconditionFailure("Bird name cannot be empty") } -
19:27 - Add coverage for precondition failure with exit test
extension BirdTests { @Test func `Bird with empty name crashes`() async throws { await #expect(processExitsWith: .failure) { _ = Bird(name: "") } } }
-
-
- 0:07 - Introduction
How to fearlessly migrate from XCTest to Swift Testing using the new interoperability feature.
- 1:08 - Swift Testing basics
A quick review of core Swift Testing building blocks — the @Test macro, #expect, and how they compare to XCTest assertions.
- 2:50 - Migration strategy
Covers the recommended incremental approach: leave existing XCTests in place, and start writing new tests in Swift Testing right away.
- 5:48 - Test framework interoperability
Introduces the interoperability feature that lets you safely call XCTest or Swift Testing API from within a test belonging to the other framework.
- 7:43 - Interoperability modes
Walks through the four interoperability modes — Limited, Complete, Strict, and None — and how to configure them in Xcode Test Plans and Swift packages.
- 13:02 - Common migration patterns
Covers practical patterns you will encounter during migration, including replacing XCTSkip with Test.cancel or traits, and continueAfterFailure with #require.
- 15:34 - Parameterized tests
Shows how to replace loop-based XCTest cases with Swift Testing parameterized tests for faster parallel execution and clearer failure reporting.
- 18:02 - Exit tests
Demonstrates how to use Swift Testing exit tests to cover code paths that call preconditionFailure or crash, running them safely in a child process.
- 20:04 - Next steps
Recaps the migration path, highlights Swift Testing open-source availability and cross-platform support, and encourages community participation.