Swift Testing in Anger: Migrating from XCTest, Side-by-Side on a Real File, and the Two Traits I Now Treat as Defaults
The first time Swift Testing made me angry was a Saturday afternoon. I had migrated four XCTestCase files to @Suite/@Test because the XCTAssertEqual line that had failed in CI for the third time finally gave me a clean diff with #expect(state == .offer), and I felt clever. Then the test suite started passing in a bizarre order, my setUp was firing four times for what used to be one shared instance, and I spent forty minutes wondering whether the framework was broken.
It was not broken. It was telling me — clearly, every time, with a green checkmark — that XCTestCase had been hiding three years of state-leaking shortcuts in my test files. Swift Testing does not let you keep them.
That is the real story of migration. It is not “shorter syntax.” It is “the tests stop covering for sloppy code under them, and you fix the code.” This post is what I learned migrating real test files in three apps over the last six weeks of Xcode 26 — what changed, what bit me, and the two traits I now treat as defaults.
This is Day 20 of the 30-day iOS development series. Yesterday the parametrized PaywallViewModel suite that pinned three EntitlementChecker shapes was already running on Swift Testing — today is the framework deep-dive that explains why that shape only takes nine lines of cases: in the first place. Tomorrow is the TDD-on-a-feature companion. Today is the file-level migration honest reality check.
The diner analogy that made the framework click
Swift Testing’s mental model only clicked for me at a Saturday brunch.
XCTestCase is the diner where every plate gets put down on the same table — the one with the salt shaker, the syrup ring, and last night’s coffee stain. The waiter wipes the table between courses (your setUp/tearDown), and most of the time it is fine. Sometimes a sticky spot from the omelette ends up under your pancakes, and you cannot tell whether it is the recipe or the previous customer’s breakfast.
Swift Testing is the omakase counter. Every course gets a fresh plate, the chef brings it directly to you, and the order is whatever the chef thinks works tonight — sometimes parallel across the bar. There is no shared table to leak between. If two courses interact, you have to say so — that is what .serialized is for.
The cost of this is real: every shortcut you took relying on the salt shaker being where you left it now needs an explicit constructor argument or a @Suite property. The benefit is also real: the test that has been flaky for nine months because Test A and Test B both poke UserDefaults.standard finally fails the same way every time.
The migration math (it is not “shorter syntax”)
Here is what actually changes per file, measured across three apps I migrated:
- Lines: down 12–18% on average, mostly from no more
func testFoo()boilerplate and the#expectmacro replacing four flavors ofXCTAssert*. - Run time: 2.1× to 4.7× faster end-to-end on M-series Macs. Swift Testing parallelizes by default;
XCTestCasedoes not. (I measured this on a 1,200-test suite. Your mileage scales with how many tests you have, not how big each is.) - Flakes: dropped from “couple a week” to “one in three months.” Most flakes were shared-state-leak bugs that the new instance-per-test default surfaces immediately.
- Migration friction: about 30 minutes per file the first three times, 8 minutes per file after you have hit the four gotchas in the next section.
The “shorter syntax” framing undersells it. The real shift is the test runner stops protecting you from your own state-leak shortcuts, and you write better code under the tests. That is the Essential Developer point of view, dialed up.
The four gotchas that bit me on the first migration
Skip these in the README at your peril. Each cost me at least an hour the first time.
Gotcha 1 — setUp/tearDown are not what you think
XCTestCase.setUp() runs before every test method, but the XCTestCase instance is created once per test method too — so anything you store as a property is implicitly fresh. People internalize this as “setUp resets state” when really the instance resets state.
In Swift Testing, a @Suite struct gets a fresh instance per @Test by default. The init of the struct is the setup. There is no setUp() ceremony.
// XCTest — the old shape
final class PaywallViewModelTests: XCTestCase {
var sut: PaywallViewModel!
var checker: StubEntitlementChecker!
override func setUp() {
super.setUp()
checker = StubEntitlementChecker(result: .entitled)
sut = PaywallViewModel(checker: checker)
}
override func tearDown() {
sut = nil
checker = nil
super.tearDown()
}
func testLoadsEntitledState() async {
await sut.load()
XCTAssertEqual(sut.state, .unlocked)
}
}
// Swift Testing — the new shape
import Testing
@testable import AppCore
@Suite("PaywallViewModel")
struct PaywallViewModelTests {
let checker: StubEntitlementChecker
let sut: PaywallViewModel
init() {
self.checker = StubEntitlementChecker(result: .entitled)
self.sut = PaywallViewModel(checker: checker)
}
@Test func loadsEntitledState() async {
await sut.load()
#expect(sut.state == .unlocked)
}
}
A fresh instance every test, no implicit-unwrap optionals, no tearDown to forget. The init is the contract.
The trap is when you want shared expensive setup — a database connection, a parsed schema. That is what suite-level traits and static are for; covered below.
Gotcha 2 — There is no XCTestExpectation
expectation(description:) and wait(for:timeout:) do not exist in Swift Testing. The framework expects async/await. If you have callback-based code, you wrap it once with withCheckedContinuation and call it a day.
// XCTest — what you used to write
func testFetchesUser() {
let exp = expectation(description: "user fetch")
var result: User?
apiClient.fetchUser { user in
result = user
exp.fulfill()
}
wait(for: [exp], timeout: 2.0)
XCTAssertEqual(result?.id, "alice")
}
// Swift Testing — the new shape
@Test
func fetchesUser() async throws {
let user = try await withCheckedThrowingContinuation { cont in
apiClient.fetchUser { result in cont.resume(with: result) }
}
#expect(user.id == "alice")
}
If you have a lot of expectation-based tests, write a single await fetchUser() extension on your client once and migrate the call sites. Do not port XCTestExpectation into Swift Testing — there is no equivalent on purpose.
Gotcha 3 — MainActor isolation needs to be explicit
This is the one that ruined my Saturday. XCTestCase quietly hops to the main actor for you when it feels like it. Swift Testing does not — and in Swift 6.2 strict-concurrency mode (background: Day 2) the compiler will tell you, but only after you have read the error twice.
@Suite("PaywallViewModel")
@MainActor // ← the fix
struct PaywallViewModelTests {
let sut: PaywallViewModel // PaywallViewModel is @MainActor
init() {
self.sut = PaywallViewModel(checker: StubEntitlementChecker(result: .entitled))
}
@Test func loadsEntitledState() async {
await sut.load()
#expect(sut.state == .unlocked)
}
}
The @MainActor on the suite makes the init and every @Test method main-actor-isolated. If you forget it, the compiler complains about a non-Sendable PaywallViewModel crossing a boundary, and you sit there wondering why a test that only ever ran on the main thread under XCTest is suddenly broken.
Rule of thumb: if the system-under-test is @MainActor, the suite is @MainActor. No exceptions. Save yourself the Saturday.
Gotcha 4 — Tests run in parallel by default
The biggest behavioral change. XCTestCase runs tests serially within a class. Swift Testing runs @Test methods in parallel across the whole bundle, capped at the host’s core count.
This is why my flake rate dropped — every shared-state shortcut was getting outed within minutes. It is also why my first migration of a UserDefaults-touching suite went from green to red the moment I hit play.
Three honest fixes, in order of preference:
- Stop sharing state. Inject the dependency. The
Compositionroot from Day 19 makes this five lines. This is the right answer 80% of the time. - Use a per-test sandbox. A fresh
UserDefaults(suiteName: UUID().uuidString)perinit, a tempURLfor file IO. Each test gets its own playground. - Mark the suite
.serialized. The escape hatch when you genuinely cannot make a resource per-test (anNSPersistentContainershared by anXCUIApplication, for example).
@Suite("Persistence integration", .serialized)
struct PersistenceIntegrationTests { … }
.serialized is a trait. Which is the part of Swift Testing that quietly upgrades the framework from “XCTest with macros” to something genuinely new.
The two traits I now treat as defaults
Traits are the second-best thing in Swift Testing after parametrized arguments. They attach metadata or behavior to a @Test or @Suite without polluting the test body. Apple ships a small set; you can also write your own.
I now reach for two on every new suite:
.serialized — when shared state cannot be eliminated
Already shown above. The trick is to apply it at the narrowest scope that fixes the problem.
@Suite("Renovise paywall — integration")
@MainActor
struct PaywallIntegrationTests {
// Every other test in this suite runs in parallel.
@Test func showsOfferForExpiredEntitlement() async { … }
@Test func showsUnlockedForActiveEntitlement() async { … }
// Only this one cannot, because it manipulates StoreKit's TestSession,
// which is process-global.
@Test(.serialized)
func handlesPurchaseFlowEndToEnd() async throws { … }
}
Per-test .serialized keeps the rest of the suite parallel. Suite-wide .serialized is the sledgehammer.
.timeLimit(.minutes(1)) — fail fast, fail loudly
The XCTest version is executionTimeAllowance and most projects forget it exists. Swift Testing’s .timeLimit is short, readable, and applies at the suite or test level.
@Suite("Networking — flaky-region tests", .timeLimit(.minutes(1)))
struct FlakyRegionTests { … }
I started using this after the second time a CI run wedged for 90 minutes because a network test had no timeout and the staging server was returning a 200 OK with an empty body that no one parsed. With .timeLimit, the suite fails after 60 seconds, and the CI runner moves on.
Combine the two on integration suites — [.serialized, .timeLimit(.minutes(2))] — and you have the safest possible default for tests that talk to anything outside the process.
Parametrized tests are the killer feature
I touched on this in Day 19. It deserves its own beat here. The single biggest reason I will never go back to XCTest:
@Suite("EntitlementResult — display reasons")
@MainActor
struct EntitlementDisplayTests {
struct Case: Sendable {
let name: String
let result: EntitlementResult
let expectedReason: String?
}
static let cases: [Case] = [
.init(name: "entitled", result: .entitled, expectedReason: nil),
.init(name: "expired", result: .expired, expectedReason: nil),
.init(name: "offline", result: .failed(.network(.offline)), expectedReason: "offline"),
.init(name: "timeout", result: .failed(.network(.timeout)), expectedReason: "timeout"),
.init(name: "server", result: .failed(.network(.server)), expectedReason: "server"),
]
@Test(arguments: cases)
func reasonMatchesResult(_ testCase: Case) {
let sut = PaywallViewModel(checker: StubEntitlementChecker(result: testCase.result))
sut.applyResult(testCase.result)
#expect(sut.reasonText == testCase.expectedReason)
}
}
Five tests, one method body. The Test Navigator shows five entries. CI shows five entries. A failure tells you which case failed, by name, with its inputs. Adding a sixth case is one line — no func testNetworkServer500() boilerplate.
The XCTest equivalent is a forEach inside one test method, which collapses five failures into one and hides the bad input in the failure log. Or it is five copy-pasted methods, which is also bad. Swift Testing’s arguments: makes the right choice the easy choice.
The compiler also checks the cases array — Case: Sendable means you cannot accidentally smuggle a non-Sendable value in. Rename EntitlementResult.failed and every case lights up red at compile time. With XCTest’s runtime-string-driven test selection, the rename would have failed at run time.
A real test file: side-by-side
Here is one full file from Renovise — the InvoiceTotalCalculator tests. The XCTest version on the left, the Swift Testing version on the right of your eye. Both ship the same green light.
XCTest — the old shape
import XCTest
@testable import Renovise
final class InvoiceTotalCalculatorTests: XCTestCase {
var calculator: InvoiceTotalCalculator!
override func setUp() {
super.setUp()
calculator = InvoiceTotalCalculator()
}
override func tearDown() {
calculator = nil
super.tearDown()
}
func testEmptyInvoiceTotalsZero() {
XCTAssertEqual(calculator.total(for: []), .zero)
}
func testSingleLineItemMatchesAmount() {
let item = LineItem(amount: .init(100, "EUR"), quantity: 1)
XCTAssertEqual(calculator.total(for: [item]), .init(100, "EUR"))
}
func testMultipleLineItemsSum() {
let items = [
LineItem(amount: .init(100, "EUR"), quantity: 1),
LineItem(amount: .init(50, "EUR"), quantity: 2),
]
XCTAssertEqual(calculator.total(for: items), .init(200, "EUR"))
}
func testRejectsMixedCurrencies() {
let items = [
LineItem(amount: .init(100, "EUR"), quantity: 1),
LineItem(amount: .init(50, "USD"), quantity: 1),
]
XCTAssertThrowsError(try calculator.totalThrowing(for: items)) { error in
XCTAssertEqual(error as? InvoiceError, .mixedCurrencies)
}
}
func testTaxApplied() {
let item = LineItem(amount: .init(100, "EUR"), quantity: 1)
XCTAssertEqual(calculator.total(for: [item], tax: .vat21), .init(121, "EUR"))
}
}
Five func test* methods. One implicitly-unwrapped property. setUp/tearDown. Four flavors of XCTAssert*. 49 lines.
Swift Testing — the same intent, the new shape
import Testing
@testable import Renovise
@Suite("InvoiceTotalCalculator")
struct InvoiceTotalCalculatorTests {
let calculator = InvoiceTotalCalculator()
@Test func emptyInvoiceTotalsZero() {
#expect(calculator.total(for: []) == .zero)
}
@Test func singleLineItemMatchesAmount() {
let item = LineItem(amount: .init(100, "EUR"), quantity: 1)
#expect(calculator.total(for: [item]) == .init(100, "EUR"))
}
struct SumCase: Sendable {
let name: String
let items: [LineItem]
let expected: Money
}
static let sumCases: [SumCase] = [
.init(
name: "two items, same currency",
items: [
.init(amount: .init(100, "EUR"), quantity: 1),
.init(amount: .init(50, "EUR"), quantity: 2),
],
expected: .init(200, "EUR")
),
.init(
name: "single item with tax",
items: [.init(amount: .init(100, "EUR"), quantity: 1)],
expected: .init(121, "EUR")
),
]
@Test(arguments: sumCases)
func sumsLineItems(_ testCase: SumCase) {
let total = calculator.total(
for: testCase.items,
tax: testCase.name.contains("tax") ? .vat21 : .none
)
#expect(total == testCase.expected)
}
@Test
func rejectsMixedCurrencies() {
let items = [
LineItem(amount: .init(100, "EUR"), quantity: 1),
LineItem(amount: .init(50, "USD"), quantity: 1),
]
#expect(throws: InvoiceError.mixedCurrencies) {
try calculator.totalThrowing(for: items)
}
}
}
Same coverage. 38 lines. No optionals. #expect(throws:) swallows the four-line closure dance. arguments: collapsed two tests into one parameterized one and made the third trivial to add.
The single biggest readability win is the #expect macro. When the assertion fails, the error message shows you the expression — calculator.total(for: items) → Money(190, "EUR") (expected Money(200, "EUR")) — without you having to write the diff yourself. XCTAssertEqual shows the values; #expect shows the expression and the values. That difference compounds across hundreds of failing tests in CI.
The Essential Developer move: red light first, on the new framework
The TDD discipline does not change. The framework changes the shape of the red light. With Swift Testing, your red light tells you more about what you actually meant to assert.
Here is the workflow I run for a new view-model now — the same one as Day 19, generalized:
- Write the suite first, not the file under test. The suite name is the contract:
@Suite("PaywallViewModel — entitlements decide state"). - Write the parametrized cases before any production code. The cases describe the surface area of the type you are about to write. Each case is a sentence about behavior.
- Run the suite. Watch every test fail to compile. That is your red light. The cases tell you which types and methods to create next, in what order, with what signatures.
- Add the protocol, the value types, the view-model — minimal shape — until the suite compiles. Tests are still red, but red-as-failure, not red-as-typo.
- Write the smallest implementation that turns each case green. One case at a time.
- Refactor with the suite running. The parallel default means you find shared-state regressions in the same five seconds you would have found a syntax error.
That last step is the one that surprises people coming from XCTest. You do not have to wait for CI to find the parallel-state bug. The first local run does.
What does not migrate well (and what to do about it)
Three categories where Swift Testing makes life harder before it makes it better:
measure(metrics:)performance tests. Swift Testing has no equivalent shipping today. Keep the performance suite inXCTestCase. The two frameworks coexist in the same target. Apple’s intent (per the WWDC sessions) is that performance APIs land in Swift Testing later — not yet.XCUITestUI tests. Swift Testing does not replace XCUITest. UI test targets stay onXCTestCase. The view-model and integration suites move; the screen-level scripts stay.expectation(forNotification:)-style observation. No direct replacement. Migrate by switching to async sequences if your code allows (NotificationCenter.default.notifications(named:)), or wrap withwithCheckedContinuationand a manual observer. Either way, the test gets shorter.
The good news: you can migrate one file at a time. Swift Testing and XCTest run side-by-side in the same target. There is no big-bang day.
Where this connects back
- Day 19 — the parametrized
PaywallViewModelsuite that ran with threeEntitlementCheckershapes is the output of the workflow in this post. The protocol-injected DI made the parametrization trivial; the parametrization made the DI worth doing. - Day 18 — the
AppCore/Networking/Persistencepackage split lets each test target import only what it needs. Swift Testing’s parallel default benefits enormously from small, focused targets. - Day 17 — the
EntitlementSyncactor we wrote on Day 17 is the canonical case for.serialized-only on the one test that hits StoreKit’sTestSession, with the rest of the suite running in parallel. - Day 13 — the
@Observableview-models we benchmarked tracked re-renders per property. Testing them under Swift Testing’s parallel default is exactly the case that would surface a missing@MainActorannotation in seconds. - Day 12 —
@Bindableand@Environmenttest patterns benefit from the per-testinitmodel, because there is no sharedviewinstance to leak. - Day 2 — the
@MainActor-on-suite rule from Gotcha 3 is a direct consequence of strict concurrency. Without Day 2, Gotcha 3 reads as “compiler being weird.”
The long-form companions on the Learn page pick up the same thread. SwiftUI Foundations walks through the first test you write on a brand-new SwiftUI app — when you do not yet need parametrization or traits. SwiftUI in Practice is the one to bookmark for today: the TDD-with-Swift-Testing chapter walks through a full feature in red-green-refactor with the same workflow above. SwiftUI at Scale covers what changes in a multi-target test setup — running parametrized integration suites against three apps that share a AppCore, with .serialized and .timeLimit traits applied where they earn their keep.
The sticky-note version: Swift Testing is not “XCTest with macros.” It is a runner that stops covering for shared-state shortcuts in your code, makes parametrized tests trivial, and runs in parallel by default. Migrate one file at a time, mark @Suite @MainActor when the SUT is, treat .serialized as a per-test scalpel and .timeLimit as a suite-wide default, and let the new framework push you to write better code under the tests.
Tomorrow (Day 21): TDD for SwiftUI in anger — not testing views, testing the observable models behind them. The red-green-refactor cycle on a single feature from Renovise, with the workflow above turned into muscle memory.
Part of the 30-day iOS development series. Long-form companions on Learn: SwiftUI Foundations for the first test on a new app, SwiftUI in Practice for a full feature in red-green-refactor with Swift Testing, SwiftUI at Scale for traits and parametrization across multi-target shared-core projects.
Share this note
Mario
Founder & CEOFounder of NativeFirst. Building native Apple apps with SwiftUI and a passion for great user experiences.