Swift Testing vs XCTest in 2026: Should You Migrate Yet?
Swift Testing has matured. Here's a side-by-side syntax comparison, the specific cases where migrating costs more than it's worth, and why the interop improvements matter more than any single feature.
Every few years, Apple hands iOS developers a new testing framework and a familiar question: is this worth the disruption, or is it safer to wait? Swift Testing landed in 2024 as the heir apparent to XCTest, and with this year's WWDC updates, it has matured enough that the question has shifted from "is it ready?" to "is it ready for your codebase?"
Here's where things actually stand.
In this guide:
- What's new this cycle
- Swift Testing vs XCTest: the syntax, side by side
- Feature comparison table
- Where XCTest still wins
- A practical migration path
- My take
- FAQ
What's Actually New This Cycle
Swift Testing's latest round of updates addresses the gaps that made teams hesitate to commit to it fully.
Configurable issue severity. You can now record an issue as a warning rather than a failure, so problems worth flagging don't have to block a CI pipeline:
Issue.record(
"Cache hit rate dropped below 80%",
severity: .warning
)
Dynamic test cancellation. Test.cancel lets a test stop itself mid-run with a message. This is most useful in parameterised tests, where you can cancel individual arguments that shouldn't run rather than letting them burn CI minutes or fail noisily:
if device.formFactor == .watch {
try Test.cancel("Layout test doesn't apply to watchOS")
}
Flaky test repetition. swift test can now repeat a test until it passes or fails, with a configurable maximum number of repetitions. Crucially, when repeating until pass, only the failing tests are re-run — you're not paying to re-execute a suite that's already green.
But the change that actually matters for the migration question is the improved two-way interoperability with XCTest. Previously, adopting Swift Testing meant picking a lane: new tests here, legacy tests there, with limited ability to share infrastructure cleanly. That's no longer true. A Swift Testing test can call a helper that internally uses XCTFail, and an XCTest can use Issue.record and the expectation macros.
There are three interop modes, and knowing which one you're in matters:
- Limited — cross-framework issues surface as warnings and tests still pass. This is the default for test plans created before Xcode 27.
- Complete — those same warnings become failures. This is the default for new Xcode 27 projects.
- Strict — a cross-framework call triggers a fatal error at the point of the bad call. Deliberately aggressive, and genuinely useful when you want to systematically find every call site that needs replacing.
You set the mode in Test Plan settings under Test Execution, or via an environment variable for Swift Package projects:
SWIFT_TESTING_XCTEST_INTEROP_MODE=strict swift test
Complete mode in SPM requires swift-tools-version: 6.4 or newer.
The one hard rule that remains: Swift Testing tests cannot live inside an XCTestCase subclass. Everything else — same target, same file, mixed freely — is fair game.
Swift Testing vs XCTest: The Syntax, Side by Side
The ergonomics argument is hard to feel from prose. Here's the same test written both ways.
The XCTest version
import XCTest
@testable import Checkout
final class CartTotalTests: XCTestCase {
var cart: Cart!
override func setUp() {
super.setUp()
cart = Cart(currency: .gbp)
}
override func tearDown() {
cart = nil
super.tearDown()
}
func testTotalIncludesVAT() {
cart.add(Item(price: 100, vatRate: 0.20))
XCTAssertEqual(cart.total, 120)
}
func testEmptyCartTotalIsZero() {
XCTAssertEqual(cart.total, 0)
}
}
Note what the framework is imposing here rather than what the test is expressing: a class you must inherit from, an implicitly unwrapped optional because setUp runs after init, a manual tearDown to release it, and method names that must begin with test and end up in camelCase in your test output.
The Swift Testing version
import Testing
@testable import Checkout
@Suite("Cart totals")
struct CartTotalTests {
let cart = Cart(currency: .gbp)
@Test("Total includes VAT at 20%")
func totalIncludesVAT() {
cart.add(Item(price: 100, vatRate: 0.20))
#expect(cart.total == 120)
}
@Test("Empty cart totals zero")
func emptyCartTotalIsZero() {
#expect(cart.total == 0)
}
}
A struct instead of a class. A stored property instead of an implicitly unwrapped optional — Swift Testing creates a fresh instance per test, so init is your setup and there's nothing to tear down. Human-readable display names alongside the function names. And one macro, #expect, in place of XCTest's forty-plus XCTAssert variants.
That last point is worth dwelling on. #expect doesn't just consolidate the API surface; because it's a macro, a failure reports the evaluated subexpressions rather than just the two operands. XCTAssertEqual(cart.total, 120) tells you 115 didn't equal 120. #expect(cart.total == 120) shows you the expression that produced 115 — which is usually the thing you actually wanted to know.
If you prefer, Swift's raw identifiers let you drop the camelCase entirely:
@Test func `Total includes VAT at 20%`() {
#expect(cart.total == 120)
}
Parameterised tests: the real ergonomics gap
Side-by-side syntax is a matter of taste. Parameterised testing is where the gap becomes structural.
In XCTest, testing several inputs means either a loop inside one test, which collapses every case into a single pass/fail, or one method per case:
func testVATRates() {
for (price, rate, expected) in [
(100.0, 0.20, 120.0),
(100.0, 0.05, 105.0),
(100.0, 0.00, 100.0)
] {
let cart = Cart(currency: .gbp)
cart.add(Item(price: price, vatRate: rate))
XCTAssertEqual(cart.total, expected)
}
}
When this fails, you know a rate is wrong. You don't know which one, and the loop stops at the first failure so you don't learn whether the others were fine.
Swift Testing generates a distinct test case per argument:
@Test("VAT is applied at the correct rate", arguments: [
(price: 100.0, rate: 0.20, expected: 120.0),
(price: 100.0, rate: 0.05, expected: 105.0),
(price: 100.0, rate: 0.00, expected: 100.0)
])
func vatRates(price: Double, rate: Double, expected: Double) {
let cart = Cart(currency: .gbp)
cart.add(Item(price: price, vatRate: rate))
#expect(cart.total == expected)
}
Every case runs, in parallel, and the test navigator tells you precisely which inputs failed. You can also pass multiple collections and get the full cross product — @Test(arguments: currencies, vatRates) generates a case for every combination.
Exit tests: coverage for code that's meant to crash
One capability with no XCTest equivalent at all. Defensive guards using precondition or fatalError have historically been untestable, because triggering them takes down the whole test process:
@Test("Cart rejects negative prices")
func negativePriceCrashes() async {
await #expect(processExitsWith: .failure) {
_ = Item(price: -10, vatRate: 0.20)
}
}
Swift Testing runs the closure in a child process, so the crash is isolated. Supported on macOS, Linux, FreeBSD, and Windows.
Swift Testing vs XCTest: Feature Comparison
| Swift Testing | XCTest | |
|---|---|---|
| Test declaration | @Test on any function; @Suite on a type |
test-prefixed methods on an XCTestCase subclass |
| Assertions | #expect and #require |
40+ XCTAssert* functions |
| Setup / teardown | init per test; deinit for class or actor suites |
setUp / tearDown, plus setUpWithError variants |
| Parameterised tests | Built in via @Test(arguments:); one case per argument, run in parallel |
Manual loops or duplicated methods |
| Parallelism | On by default, in-process | Opt-in, process-based |
| Flaky-test handling | Repeat-until-pass with a retry cap; only failing tests re-run | Test repetition in Xcode, coarser control |
| Issue severity | Configurable — .warning doesn't fail the build |
Failures only |
| Dynamic cancellation | Test.cancel, including per-argument |
XCTSkip variants, evaluated up front |
| Crash testing | Exit tests via #expect(processExitsWith:) |
Not supported |
| UI automation | Not supported | XCUIApplication |
| Performance testing | Not supported | XCTMetric, measure |
| Objective-C | Not supported | Full support, including exception catching |
| Interop | Two-way, with limited / complete / strict modes | Same |
| Platforms | macOS, iOS, watchOS, tvOS, visionOS, Linux, Windows | Apple platforms, plus swift-corelibs-xctest on Linux |
| Tooling maturity | Good and improving; some third-party reporters still catching up | Mature, universally supported |
Where XCTest Still Wins
None of this makes XCTest obsolete, and it's worth being specific about where migrating would genuinely cost more than it's worth.
UI automation. Anything driving XCUIApplication stays in XCTest. Swift Testing has no UI automation story, and Apple hasn't signalled one is coming. If a meaningful share of your suite is XCUITest, that portion of your codebase isn't part of this conversation.
Performance tests. XCTMetric and measure have no Swift Testing equivalent. If you're tracking app launch time, scroll frame rate, or memory footprint as regression gates — and on mobile you probably should be — those tests stay put.
Objective-C exception catching. Only Objective-C code can safely catch Objective-C exceptions. Any test that relies on this has to remain in XCTest, and no amount of interop changes that.
Suites with shared mutable state. This is the one that catches teams out. Swift Testing runs in parallel by default, in-process. A suite that quietly depended on serial execution — a shared singleton, a mutable static, a UserDefaults key written by one test and read by another — will start producing intermittent failures that look like framework problems but are actually latent race conditions in your test code. The fix is to isolate the state properly, not to blanket everything in .serialized. But if you're mid-sprint on a deadline, discovering that your fixtures were never as isolated as you assumed is not the surprise you want.
Third-party test infrastructure you don't control. Some reporters, snapshot libraries, and CI dashboards were built against XCTest's output format first. Most have caught up, but "most" isn't "all" — and if your flaky-test tracker or your coverage gate silently stops seeing half your suite, you'll find out later than you'd like. Check what your pipeline actually consumes before you commit.
Large, stable, green suites. If you've got years of XCTest coverage that passes reliably, rewriting it delivers no functional improvement. The tests still catch the same bugs. The only thing that changes is your risk of introducing a new one during the port.
A Practical Migration Path
Given the interop improvements, the sensible approach for most teams isn't a wholesale rewrite:
- Leave your existing XCTest suite alone. If it's green and reliable, there's no return on touching it purely for migration's sake.
- Write new tests in Swift Testing. This is where the ergonomics pay off — on code you're writing today rather than code you'd need to retrofit.
- Start in limited interop mode, then tighten. Limited surfaces cross-framework calls as warnings so you can see the scope of the problem without breaking the build. Move to complete once you've cleaned up, and use strict as a temporary audit tool when you want the compiler to march you through every remaining call site.
- Hunt for loops. Any test with a
forloop over inputs is a parameterised test waiting to happen, and converting it is the single highest-value refactor available. You go from one opaque pass/fail to per-case results, for roughly no effort. - Revisit legacy suites opportunistically, not proactively. If you're already in an old test file for an unrelated reason, that's a reasonable moment to port it. As a dedicated project with no other justification, usually not.
Migrate shared helpers first. They're the highest-leverage change and the smallest. XCTFail becomes Issue.record, and the file/line pair becomes a single SourceLocation:
// Before
func assertValidTotal(_ cart: Cart,
file: StaticString = #filePath,
line: UInt = #line) {
if cart.total < 0 {
XCTFail("Negative total", file: file, line: line)
}
}
// After — callable from both frameworks
func assertValidTotal(_ cart: Cart,
sourceLocation: SourceLocation = #_sourceLocation) {
if cart.total < 0 {
Issue.record("Negative total", sourceLocation: sourceLocation)
}
}
Two translation notes that come up constantly: XCTSkipIf is better expressed as an .enabled(if:) trait than as a Test.cancel call, because it moves the condition out of the test body and into the declaration. And continueAfterFailure = false has no direct equivalent — you use try #require for the assertions that should halt the test, which gives you per-assertion control instead of one global switch.
My Take
I wouldn't migrate a stable, working XCTest suite wholesale just because Swift Testing exists. That's effort spent on novelty rather than on the problem. But for new projects, or new test files in an existing project, reaching for XCTest by default is now worth questioning.
The parameterised testing gap is the honest headline, not the syntax. Having worked across Android and server-side Swift as well as Apple platforms, the thing that's struck me about @Test(arguments:) is that JUnit 5 and pytest have had this for years. Swift Testing isn't inventing anything here — it's closing a gap that made Apple-platform testing feel dated by comparison. Anyone coming to iOS from those ecosystems has been writing the loop-inside-a-test workaround and wondering why. That's now over, and it's the change most likely to alter how you actually write tests rather than just how they look.
Parallel-by-default is a genuine trade-off, not a free win. It's the right default, and I wouldn't want it changed. But it converts a class of latent bugs in your test code into visible, intermittent failures, and it does so at exactly the moment you adopt the framework — which makes it easy to blame the framework. Budget time for it. If your existing suite has any shared state at all, the first week will be spent fixing tests rather than writing them.
The cross-platform story is underrated. Swift Testing runs on Linux and Windows properly, not as an afterthought. If you're sharing Swift code between an iOS app and a Linux server — or increasingly Android, now that Swift's reach has broadened there — a single test framework across the whole codebase is worth more than any individual feature in the comparison table. XCTest on Linux via corelibs always felt like a port. This doesn't.
Flaky-test repetition deserves a caveat. It's a real quality-of-life improvement if your CI has ever had a test that fails one run in twenty for reasons nobody's tracked down — which, if you've maintained a mobile test suite for any length of time, it almost certainly has. But retry-until-pass is a diagnostic, not a cure. A test that needs three attempts is telling you something about your code or your fixtures, and the risk of a good retry mechanism is that it makes the message easy to ignore. Use it to reproduce and investigate. Please don't leave it switched on as a permanent workaround—that's how you end up shipping a race condition to users who don't get a retry.
And the interop is the real story. More than any individual feature, it means the decision is no longer "Swift Testing or XCTest." It's "which one for this test, right now" — and that's a far more comfortable position to make decisions from.
A Quick Note on Swift 6.4
Worth a brief mention alongside this, since it shipped in the same cycle. Swift 6.4 introduces anyAppleOS, a shorthand that collapses the five-line @available blocks covering iOS, macOS, watchOS, tvOS, and visionOS into a single condition, and it works in #if os(anyAppleOS) too. It also finally allows await inside defer, so async cleanup runs on every exit path — including when a function throws — without duplicating it.
Neither is testing-specific, but both reduce the kind of boilerplate that quietly erodes a codebase over time, and they're worth adopting the moment you're on Xcode 27 regardless of where you land on the testing question.
Bottom Line
If you're starting something new: default to Swift Testing. If you're maintaining something old and stable: leave it be, and let the interop bridge the gap when the two frameworks genuinely need to meet.
The migration question this cycle isn't really about which framework wins. It's about not treating it as an all-or-nothing decision anymore.
Swift Testing vs XCTest FAQ
Is XCTest deprecated?
No. Apple hasn't deprecated XCTest, and it still exclusively owns UI automation, performance testing, and Objective-C support. But every new Apple sample project and session since 2024 points Swift code at Swift Testing, so treat it as the default for new work rather than as a replacement you're being forced into.
Can Swift Testing and XCTest coexist in the same target?
Yes — in the same target, and even in the same file. The one restriction is that Swift Testing tests cannot live inside an XCTestCase subclass.
Does Swift Testing support UI tests?
No. UI automation stays in XCTest with XCUIApplication, and Apple has given no indication that's changing.
What replaces setUp and tearDown in Swift Testing?init handles setup, since a fresh suite instance is created for each test. For teardown, deinit works if your suite is a class or actor; struct suites usually don't need it, because there's nothing to release.
How do I handle flaky tests in Swift Testing?swift test can repeat a test until it passes or fails, with a configurable retry cap, and only re-runs the failing tests. Treat it as a way to reproduce and diagnose intermittency rather than as a permanent setting.
Should I migrate my existing XCTest suite?
Generally no, not as a dedicated project. Write new tests in Swift Testing, migrate shared helpers so both frameworks can use them, and port old tests only when you're already working in those files for another reason.