Migrating to Swift Testing Without a Heroic Weekend

The reason nobody's finished migrating off XCTest was never the syntax - it was that a half-migrated suite used to fail silently. Xcode 27's interop mode is the part that actually unblocks this.

I’ve started this migration three times. Rewriting XCTAssertEqual(a, b) as #expect(a == b) was never the hard part - a find-and-replace with a bit of judgment handles most of it. The hard part was that XCTest and Swift Testing didn’t actually understand each other. An XCTAssert failure that happened to run inside a Swift Testing context, or a #expect called from inside an XCTestCase helper, just didn’t register. You could migrate a file, watch the suite stay green, and have genuinely lost coverage without a single red X to tell you.

Session 267 at WWDC 2026 is Apple’s answer to exactly that problem, and it’s less about new syntax and more about closing that hole.

What interop actually fixes

Xcode 27 turns on interoperability by default: a Swift Testing test can call a helper that internally does XCTFail, and that failure now surfaces as a real test issue. An XCTestCase can call #expect and get a real, visible failure too. The two frameworks stop being fully separate worlds - which is the thing that makes incremental migration safe instead of theoretical.

There are two modes, and which one your project is in matters:

  • Limited mode - cross-framework issues are reported as warnings. Every test plan created before Xcode 27 inherits this mode automatically, so existing projects don’t get surprise failures the day they upgrade.
  • Complete mode - the same cross-framework issues are errors. New projects default here.

You can move a project from limited to complete deliberately, once you trust the migrated portions of the suite. Don’t do it on day one of upgrading Xcode - do it once you’ve actually migrated something and want the stricter guarantee.

The actual migration strategy

Apple’s own recommendation, and it matches what worked the three times I tried this manually: don’t schedule a rewrite. Leave existing XCTestCase classes in place. Write every new test in Swift Testing. When you’re already touching a test file for an unrelated reason, migrate it then, not before.

The mechanical part, for whichever files you do touch:

  • Replace XCTFail(message) with Issue.record(message), and carry over the source location parameter - this is what keeps failures pointing at the right line instead of at whatever helper called it.
  • Nested loops driving repeated assertions are almost always a parameterized test in disguise:
@Test(arguments: [Rocket.saturnV, Rocket.falcon9, Rocket.starship])
func fuelingSequenceCompletes(_ rocket: Rocket) async throws {
    #expect(try await rocket.fuel().isComplete)
}
  • Anywhere you have a preconditionFailure or fatalError path with no test coverage - because there was no good way to assert a crash in XCTest - Swift Testing’s exit tests cover it directly:
@Test func rejectsNegativeThrust() async {
    await #expect(processExitsWith: .failure) {
        engine.setThrust(-1)
    }
}

That last one is worth going back for even outside a broader migration. “This crashes on bad input” was one of the harder things to actually prove in XCTest, and now it’s a normal test.

What I’d actually do this week

Turn on Xcode 27’s default interop, leave your project in limited mode, and migrate the next test file you touch for an unrelated reason - not the whole suite. Once a meaningful chunk is migrated and you trust it, flip to complete mode so cross-framework issues start failing the build instead of just warning. That’s the whole plan. The framework finally makes “a little at a time” a safe strategy instead of a hopeful one.