Swift Testing went from "cool new thing" at WWDC 2024 to "the default for new test code" in Xcode 26 / Swift 6.2. If you have an iOS codebase that is still entirely in XCTest, the question in April 2026 is not whether to migrate but how fast. This is the practical migration guide. No "hello world," no marketing. Real side-by-side patterns from production Swift apps.
Short version: Swift Testing is strictly better than XCTest for almost everything except UI tests and XCTMetric performance tests. Migrate new tests immediately, migrate old unit tests gradually (feature-by-feature is the right cadence), and leave UI tests on XCTest until Apple ships a replacement. The migration is mechanical and reversible. Both frameworks coexist in the same target without friction. Details, syntax table, parameterization patterns, trait/tag scaling, CI gotchas, and the one edge case you must not migrate below.
The migration thesis in one paragraph
Swift Testing is a macro-driven test framework co-designed with Swift Concurrency. Each test is a plain function annotated with @Test rather than a method on an XCTestCase subclass. The result is less boilerplate, better async, cleaner parameterization, and a much stronger story around tagging and filtering tests at scale. XCTest continues to work and continues to be the only option for UI tests and XCTMetric performance tests in Xcode 26, so you will run both side by side.
Side-by-side syntax: 15 patterns converted
The cleanest way to explain Swift Testing is to show the same test in both frameworks. Here are the patterns you hit every week.
| Pattern | XCTest | Swift Testing |
|---|---|---|
| Declare a test | func testSum() { | @Test func sum() { |
| Equality | XCTAssertEqual(a, b) | #expect(a == b) |
| Boolean | XCTAssertTrue(x) | #expect(x) |
| Failure with message | XCTFail("unreachable") | Issue.record("unreachable") |
| Expect throw | XCTAssertThrowsError(try f()) | #expect(throws: MyError.self) { try f() } |
| Require non-nil | let x = try XCTUnwrap(optional) | let x = try #require(optional) |
| Skip test | throw XCTSkip("reason") | withKnownIssue {...} or @Test(.disabled("reason")) |
| Group related tests | class AuthTests: XCTestCase {...} | @Suite struct AuthTests {...} |
| Setup | override func setUp() | init() on the suite struct |
| Teardown | override func tearDown() | deinit on the suite struct |
| Async test | func testX() async throws | @Test func x() async throws |
| Parameterized | Loop inside one test | @Test(arguments: [1, 2, 3]) |
| Skip on condition | try XCTSkipIf(condition) | @Test(.enabled(if: condition)) |
| Timing out a test | XCTExpectation + timeout | @Test(.timeLimit(.minutes(1))) |
| Confirming an event | expectation.fulfill() | try await confirmation {...} |
The pattern is consistent: less ceremony, more macros, explicit async, and a first-class way to parameterize tests. You will write the same assertions with roughly 30 to 40 percent fewer lines of code.
Parameterization: kill your duplicate tests
Parameterized tests are the single feature that makes teams adopt Swift Testing immediately once they see it. In XCTest, you either wrote a loop inside a test (losing per-case failure clarity) or you wrote N nearly identical test functions. In Swift Testing, you write one function.
@Test("Email validator accepts valid addresses",
arguments: [
"a@b.co",
"first.last@example.com",
"user+tag@sub.example.io",
])
func acceptsValid(email: String) {
#expect(EmailValidator.isValid(email))
}
@Test("Email validator rejects invalid addresses",
arguments: [
"",
"no-at-sign",
"@no-local.co",
"a@b",
])
func rejectsInvalid(email: String) {
#expect(!EmailValidator.isValid(email))
}Each argument produces its own reported test case. A failure on one input does not mask failures on others, and the report tells you exactly which input failed. For validators, parsers, date helpers, and currency math this is transformative.
Cross-product parameterization (two arguments: arrays) expands to every pair, which is ideal for covering matrix scenarios like roles x actions or locales x date formats.
Async tests done right
Swift Testing was co-designed with Swift Concurrency. Every test can be async, every assertion plays cleanly with actors, and the confirmation API replaces the old XCTestExpectation choreography.
@Test func userRepositoryPublishesChange() async throws {
let repo = UserRepository()
try await confirmation(expectedCount: 1) { confirm in
let sub = repo.userStream.sink { _ in confirm() }
await repo.refresh()
sub.cancel()
}
}Compared to XCTest:
- No class, no setUp/tearDown pair, no expectation outside a block.
- Async is first-class, not a second-pass bolt-on.
- The compiler tells you about Sendable violations in test code just like in app code, which catches real bugs.
Traits and tags: filtering at scale
This is the feature that pays off most in projects with 500+ tests. Swift Testing lets you tag tests and then filter runs by tag, time limit, environment, and more.
extension Tag {
@Tag static var paywall: Self
@Tag static var ai: Self
@Tag static var slow: Self
}
@Suite("Paywall view model")
struct PaywallViewModelTests {
@Test(.tags(.paywall)) func showsCurrency() { /* ... */ }
@Test(.tags(.paywall, .slow), .timeLimit(.minutes(1)))
func resolvesProductAcrossNetworkHiccup() async throws { /* ... */ }
}In CI you can then run:
- Fast loop on every PR:
swift test --filter-tag paywall --skip-tag slow - Nightly full run: no filter, runs everything including slow tests.
- Feature-scoped:
swift test --filter-tag aiwhen you are iterating on the AI module.
Traits also compose. .timeLimit, .enabled(if:), .disabled("reason"), and custom traits can be attached together. It is the most elegant opt-in/opt-out system Apple has shipped for iOS testing.
Mixing XCTest and Swift Testing in one target
You do not have to migrate everything at once. Xcode 26 runs both frameworks in the same test target without configuration. XCTest keeps working, Swift Testing runs alongside, the test navigator shows both, failures report the same way.
The practical migration I recommend:
- New tests in Swift Testing only. Enforce this in code review. Your coverage grows in the new framework by default.
- Port an XCTest file when you touch it. Any feature you are modifying, migrate its tests in the same PR. Spread across a few months, this handles 80 percent of the catalog.
- Leave UI tests and XCTMetric tests on XCTest. Apple has not shipped a replacement for either as of April 2026. Do not try to port these.
- Run a sweep at the end. After six months, most of the catalog will be migrated and the remaining XCTest files are either UI tests or dead code. Delete the dead and keep UI tests as-is.
What you still cannot migrate in 2026
Apple's migration guidance is clear on two exceptions. Do not waste time trying.
- UI tests.
XCUIApplication,XCUIElement, and all UI test runner machinery are XCTest-only. Swift Testing is built for in-process unit and integration tests. - XCTMetric performance tests. The
measure {}API, XCTClockMetric, XCTMemoryMetric, XCTCPUMetric are XCTest-only. If you rely on Xcode's performance test dashboards, keep those tests in XCTest.
Everything else (unit tests, integration tests, snapshot tests via the snapshot-testing library, actor tests, network tests, BLoC-ish presenters, repository tests) migrates cleanly.
CI gotchas I have run into
A few operational notes from running Swift Testing in Xcode Cloud, GitHub Actions, and Fastlane scan on production iOS projects.
- Xcode Cloud. Works out of the box in Xcode 16+ (it is the default for all new projects in Xcode 26). You do not need a workflow change.
- Fastlane scan. Versions of
scanbefore 2.221 need the--disable-package-automatic-updatesflag otherwise Swift Testing discovery can loop on package resolution. Upgrading to 2.225+ fixes it. - GitHub Actions with
xcodebuild. Use-skipPackagePluginValidation -skipMacroValidationon the command line when your project uses Swift Macros (most 2026 projects do). Without these flags you will hit macro signing prompts on clean runners. - Bitrise and Codemagic. Swift Testing is supported out of the box on both as of 2025. No workflow changes required. Make sure the Xcode version in the workflow is 16.0 or newer.
- Parallel execution. Swift Testing runs parallel by default (one test per CPU). If your tests share mutable global state, this will surface it. Fix the shared state, do not disable parallelism.
Real migration: a 12,000-line project in one afternoon
A recent migration I did on a mid-size SwiftUI codebase: 12,000 lines of XCTest, 380 tests across 42 files. I took one afternoon to do the initial port, then a follow-up PR per feature to clean up.
- Migrate the simplest test file first (pure functions, no async, no XCTestCase lifecycle). This gives you a reusable template for the rest.
- Run a scripted find-and-replace for the obvious syntax:
XCTAssertEqualto#expect(... == ...),XCTAssertTrueto#expect(...),XCTAssertNilto#expect(... == nil). - Convert
class X: XCTestCaseto@Suite struct X. MovesetUptoinit()andtearDowntodeinit. - Remove the leading
testin function names since the macro replaces the discovery role. - For tests with repeated loops over inputs, convert to parameterized
@Test(arguments:). This usually removes 3-to-1 in line count. - Fix the async tests. Most just need the function to be
async throws; expectations becomeconfirmation. - Run the suite. Fix any captured-reference or Sendable warnings the new macros surface. These are real bugs, not noise.
The initial port took four hours. The per-feature cleanup trickled across two weeks. Total cost: under 12 hours of focused work. The same codebase now runs the CI test suite 18 percent faster, mostly from parameterization removing duplicated setup cost.
What The Swift Kit ships
The Swift Kit ships Swift Testing by default for every new test and a small XCTest target for UI tests. The unit test templates for the bundled BLoCs, repositories, and services are all parameterized where it makes sense, tagged by feature, and run in under five seconds on a recent Mac. There is also a CI workflow template (GitHub Actions and Xcode Cloud) that runs the fast tests on every PR and the full suite nightly.
If you are starting a new Swift project in 2026, skipping the XCTest era entirely is the right default. $99 one-time, unlimited commercial projects. See every integration on the features page or jump to pricing.
Final recommendation
Migrate new tests to Swift Testing immediately. Migrate old tests as you touch them. Leave UI tests and XCTMetric performance tests on XCTest until Apple ships a replacement. Expect the full migration of a medium codebase to take a few weeks of evening work, not a quarter. The framework is stable, the tooling is first-class in Xcode, and the CI story is handled.
The trap to avoid: trying to migrate the whole codebase in a single marathon PR. Nobody can review it, you will introduce subtle parameterization bugs, and the lift is unpleasant. Migrate a feature at a time and you get the benefits incrementally with none of the risk.