Swift Testing — the Testing module, introduced with Xcode 16 and Swift 6.0, and open-sourced under the Swift project — is the first real alternative to XCTest since 2013. It’s not a rewrite of XCTest with nicer syntax. It’s a new model: compile-time test discovery, functions instead of classes, macros instead of assertions, and a trait system that replaces most of the boilerplate you’ve been writing for a decade.

I’ve spent the last year migrating teams off XCTest, and this guide covers what actually matters in production: how the model differs, what the macros do differently, parameterized tests, traits, async and strict concurrency, and a realistic incremental migration path. No hype — including the part where you should not switch.

Those fast domain tests become especially valuable when the same Swift business logic ships on Android and must stay consistent across both native apps.

They are equally important for AI features: a provider-agnostic Foundation Models architecture shows how deterministic test doubles protect routing and failure policy while model evaluations cover semantic quality.

The same split applies to system integrations: testing App Intents for Siri, Shortcuts, Spotlight, and widgets starts with fast domain tests and adds a focused suite around parameter resolution and host-process wiring.

1. What Swift Testing Is — and What’s Actually Different

XCTest is built on inheritance and naming conventions: you subclass XCTestCase, name methods testSomething(), and the runtime finds them via reflection. Swift Testing inverts that. Any function marked @Test is a test, no class required, and discovery happens at compile time through macro expansion.

// XCTest
final class CartTests: XCTestCase {
    func testTotalMatchesItemPrice() {
        let cart = Cart()
        cart.add(Item(price: 100))
        XCTAssertEqual(cart.total, 100, "Total should equal the item price")
    }
}

// Swift Testing
struct CartTests {
    @Test("Total equals the item price")
    func totalMatchesItemPrice() {
        let cart = Cart()
        cart.add(Item(price: 100))
        #expect(cart.total == 100)
    }
}

The two assertions are where the philosophy shifts. XCTAssert* takes an expression and a message; on failure you get the message you wrote (or the generic one you didn’t). #expect is a macro that captures the expression itself — on failure, the test report shows you the actual values:

Expectation failed: (cart.total == 100)
   Actual: 90
   Expected: 100

That single difference eliminates a huge class of “what failed, and why?” archaeology. The sibling macro, #require, is a throwing guard: it unwraps an optional or bails the test immediately, rather than continuing with a nil and failing three assertions later:

@Test
func parseToken() throws {
    let token = try #require(parser.token(for: "session"))
    #expect(token.expiresAfter > Date.now)
}

#require maps to XCTUnwrap — but it composes better, because it’s just a throwing expression you can use anywhere, not a special assertion.

There’s no setUp/tearDown ceremony required, and tests in the same file are grouped into an implicit suite automatically. Structs, no subclassing, no override func, no super.setUp() forgotten for the thousandth time.

2. Suites: Organization Without Inheritance

When you want an explicit grouping, you add @Suite to a type — a struct, by default, which matches the “value type, fresh instance per test” semantics you already expect from XCTestCase:

@Suite("Checkout")
struct CheckoutTests {
    let cart: Cart

    init() {
        cart = Cart()
        cart.add(Item(price: 49.99))
    }

    @Test func total() {
        #expect(cart.total == 49.99)
    }
}

The init() is your setUp — it can be async throws, which is handy for loading fixtures. The deinit can serve as your tearDown, but only for class or actor suites, and it stays synchronous: Swift deinit can never be async or throwing, and a struct suite can’t declare one at all. The instance is recreated per test, just like XCTest, so you never share state between tests by accident. A suite that genuinely needs class-like teardown (rare) can be final class instead.

Nested suites come free: a @Suite type nested inside another @Suite type becomes a child suite in the test navigator. This is how I organize larger codebases — one suite per module, nested suites per feature:

@Suite("Cart")
struct CartTests {
    @Suite("Coupons")
    struct CouponTests {
        @Test func appliesPercentageDiscount() { /* ... */ }
    }

    @Suite("Shipping")
    struct ShippingTests {
        @Test func freeAboveThreshold() { /* ... */ }
    }
}

The naming convention (testX prefixes, method-name soup) is gone. Tests have human-readable names as their primary identifier, which makes failure reports, CI logs, and --filter invocations legible.

3. Parameterized Tests: Data-Driven by Default

This is the feature I miss most when I have to touch an XCTest file. A parameterized test runs once per argument and reports each row individually — one failing input doesn’t hide the other nineteen:

@Test(arguments: [0, 1, 4, 5, 10])
func shippingCost(itemCount: Int) {
    let cart = Cart(quantity: itemCount)
    #expect(cart.shippingCost == (itemCount >= 5 ? 0 : 4.99))
}

The test navigator shows each argument as its own row: pass/fail per input, not a single boolean for the whole loop. The arguments can be ranges, collections, or — the pattern I use most — collections of tuples for multi-input cases:

@Test(arguments: [
    (price: 100.0, code: "SAVE10", expected: 90.0),
    (price: 100.0, code: "NOPE",   expected: 100.0),
    (price: 0.0,   code: "SAVE10", expected: 0.0),
])
func totalAfterDiscount(price: Double, code: String, expected: Double) {
    let vm = CheckoutViewModel(catalog: Catalog(coupons: [Coupon(code: "SAVE10", discountRate: 0.10)]))
    vm.add(price: price)
    vm.apply(couponCode: code)
    #expect(vm.total == expected)
}

This is where TDD gets faster. Edge cases become data, not copy-pasted test methods. And because each row is an independent test, a failing row doesn’t abort the others — you get the full picture in one run.

4. Traits: Declarative Configuration Replaces Boilerplate

Traits are where Swift Testing most clearly breaks from XCTest. Instead of XCTSkip, XCTExpectFailure, and manually managed ordering, you declare behavior on the test:

extension Tag {
    static let checkout: Tag = "checkout"
}

@Suite(.serialized)                       // This suite's tests run one at a time
struct CartTests {
    @Test(.tags(.checkout), .timeLimit(.minutes(1)))
    func checkoutFlow() async throws { /* ... */ }

    @Test(.disabled("Flaky — blocked on PROJ-123"))
    func legacyRounding() { /* ... */ }

    @Test(.bug(id: "PROJ-77", "Coupon applies twice"))
    func doubleCoupon() { /* ... */ }
}
  • .serialized — opts a test (or whole suite) out of parallel execution. In XCTest, ordering was alphabetical and everything was serial; in Swift Testing, parallel is the default, and .serialized is the explicit escape hatch for tests that touch shared mutable state.
  • .disabled("reason") — the replacement for XCTSkip, visible in the navigator as skipped rather than silently passing.
  • .bug(id: "PROJ-77", "...") — links a test to a tracking issue. Tests with a known bug and no fix get .expectedToFail(.bug(id: "PROJ-77")), which is far more honest than XCTExpectFailure with a comment string:
@Test(.expectedToFail(.bug(id: "PROJ-456")))
func knownRoundingIssue() { /* currently fails; that's expected */ }
  • .timeLimit(.minutes(1)) — a watchdog per test. Handy for network-dependent tests that would otherwise hang CI for the full timeout.

There are more (.enabled(if:), .comment(...), .custom(...)), and tags are first-class: filter by them in the test navigator or test plan, and use them to split “smoke” vs “full” suites. The net effect: configuration lives next to the test it configures, and the test plan shrinks to what can’t be expressed inline.

5. Async Code, Actors, and Strict Concurrency

Swift Testing was built alongside Swift Concurrency, and it shows. Test functions can be async throws directly, and #expect accepts async expressions:

@Test
func healthCheck() async throws {
    #expect(await service.isHealthy())
}

That await inside the macro works because #expect is a freestanding expression macro: its expansion runs in the test function’s own async context, so the expression is evaluated with await directly — no Task wrapper involved. Compare with XCTest, where you’re juggling XCTestExpectation or async let + wait(for:) dance.

Actors are tested by just calling them, as you would in production code:

actor OrderStore {
    private(set) var orders: [Order] = []
    func place(_ order: Order) { orders.append(order) }
}

@Test
func placingOrderAppendsToStore() async {
    let store = OrderStore()
    await store.place(Order(id: 1))
    let count = await store.orders.count
    #expect(count == 1)
}

UI-bound logic tests with @MainActor just annotate the test or suite — no more “Main Thread Checker” surprises:

@MainActor
@Test
func applyingCouponUpdatesDiscount() {
    let vm = CheckoutViewModel(catalog: catalog)
    vm.add(price: 100)
    vm.apply(couponCode: "SAVE10")
    #expect(vm.total == 90)
}

Here’s the strict-concurrency angle: tests run in parallel, so in Swift 6 language mode, parameterized test arguments must be Sendable, and the compiler enforces it at build time. That’s a feature — a non-Sendable argument would be a data race the moment two rows ran concurrently. The model choices that make your production code concurrency-safe, which I covered in depth in Struct vs Class in Swift 6: Concurrency-Safe Choices, apply to your test data too: value types in, shared state behind actors.

6. Migrating From XCTest: Incremental and Honest

The migration is a refactor, not a rewrite. Swift Testing and XCTest coexist in the same target, discovered and run side by side by the same runner. You can migrate one file per PR, and CI runs both.

If the test migration coincides with a compiler upgrade, the Swift 6.4 production migration guide covers mixed XCTest and Swift Testing suites, repeated runs, and CI toolchain pinning.

Start with what pays off fastest: pure logic and anything you’d parameterize (see the worked example below). Leave these in XCTest for now:

  • XCUITest (UI automation — see section 8)
  • Performance tests using measure()
  • Tests relying on XCTContext or legacy XCTestExpectation patterns — although confirmation(...) (Xcode 16+) and Attachment.record(...) (Xcode 26+) now cover the expectation and attachment use cases directly
  • App-hosted integration tests where your harness is already XCTest-shaped

The mechanical translation table:

// XCTest                          // Swift Testing
XCTAssertEqual(a, b)                #expect(a == b)
XCTAssertTrue(cond, msg)            #expect(cond, "msg")
XCTAssertNil / XCTAssertNotNil      #expect(x == nil) / #expect(x != nil)
XCTUnwrap(x)                        try #require(x)
XCTAssertThrowsError { ... }        #expect(throws: (any Error).self) { ... }
XCTAssertNoThrow { ... }            #expect(throws: Never.self) { ... }
XCTSkip("reason")                   .disabled("reason")  // or .enabled(if:)
XCTExpectFailure { ... }            .expectedToFail / withKnownIssue { ... }
XCTFail("msg")                      Issue.record("msg")
setUp() / tearDown()                init() (async/throws allowed) / deinit (class & actor suites only, synchronous)

Two gotchas will bite you, and both are worth knowing before you start:

Optional comparison. XCTAssertEqual is generic over a single type, so XCTAssertEqual(result?.count, 3) compiles because 3 is injected into Int?. The == operator that #expect uses does not inject on both sides, so the same shape fails to compile when the non-optional side is a variable:

let expected = 3
// #expect(result?.count == expected)   // Error: 'Int?' vs 'Int'
// Fix: unwrap, or compare optionals deliberately
#expect(result?.count == Optional(expected))

It’s a one-line fix, but expect a dozen occurrences in a mature suite. Better: make the fixture non-optional and #require the value you’re testing.

Expected failures. XCTExpectFailure wraps a block; Swift Testing’s model is declarative. A test you know is broken gets .expectedToFail(.bug(id: "PROJ-456")) — and if it starts passing, Swift Testing reports that as an issue, because a test that unexpectedly passes means your tracking is stale. For a failure that only happens mid-test, withKnownIssue { ... } scopes it locally.

Ordering. XCTest ran methods alphabetically, serial by default. Swift Testing runs in parallel, in whatever order the runner chooses. If any of your tests secretly depended on alphabetical order or shared mutable state, they’ll fail — visibly, in CI, at the worst moment. The fix is .serialized as a stopgap, and removing the shared state as the real fix.

7. A Worked Example: ViewModel With Parameterized Tests and Traits

Let’s test something real. A small checkout view model, in the spirit of the MVVM structure I described in MVVM with Clean Architecture in iOS: A Practical Guide:

struct Coupon: Sendable, Equatable {
    let code: String
    let discountRate: Double
}

struct Catalog: Sendable {
    let coupons: [Coupon]
    func coupon(for code: String) -> Coupon? {
        coupons.first { $0.code == code }
    }
}

@MainActor
final class CheckoutViewModel {
    private let catalog: Catalog
    private(set) var subtotal: Double = 0
    private(set) var appliedCoupon: Coupon?

    init(catalog: Catalog) {
        self.catalog = catalog
    }

    func add(price: Double) { subtotal += price }

    func apply(couponCode: String) {
        appliedCoupon = catalog.coupon(for: couponCode)
    }

    var total: Double {
        guard let appliedCoupon else { return subtotal }
        return subtotal - (subtotal * appliedCoupon.discountRate)
    }
}

The test suite — parameterized for the arithmetic, trait-annotated for the policy:

@Suite("CheckoutViewModel")
@MainActor
struct CheckoutViewModelTests {
    private let catalog = Catalog(coupons: [
        Coupon(code: "SAVE10", discountRate: 0.10)
    ])

    @Test(arguments: [
        (items: [12.99, 7.50], expected: 20.49),
        (items: [0.0],         expected: 0.0),
        (items: [1.0, 2.0, 3.0, 4.0, 5.0], expected: 15.0),
    ])
    func subtotalSumsItemPrices(items: [Double], expected: Double) {
        let vm = makeViewModel()
        items.forEach { vm.add(price: $0) }
        #expect(vm.subtotal == expected)
    }

    @Test(arguments: [("SAVE10", 100.0, 90.0), ("NOPE", 100.0, 100.0)])
    func totalAppliesKnownCoupons(code: String, price: Double, expected: Double) {
        let vm = makeViewModel()
        vm.add(price: price)
        vm.apply(couponCode: code)
        #expect(vm.total == expected)
    }

    @Test("Unknown coupon leaves total untouched", .tags(.checkout))
    func unknownCouponIsIgnored() {
        let vm = makeViewModel()
        vm.add(price: 100)
        vm.apply(couponCode: "DOES-NOT-EXIST")
        #expect(vm.total == 100)
        #expect(vm.appliedCoupon == nil)
    }

    @Test(.expectedToFail(.bug(id: "PROJ-77", "Floating point: 0.1 + 0.2 == 0.30000000000000004")))
    func roundingEdgeCase() {
        let vm = makeViewModel()
        vm.add(price: 0.1)
        vm.add(price: 0.2)
        #expect(vm.total == 0.3)
    }

    private func makeViewModel() -> CheckoutViewModel {
        CheckoutViewModel(catalog: catalog)
    }
}

And the same first test, before and after:

// Before — XCTest
final class CheckoutViewModelTests: XCTestCase {
    var vm: CheckoutViewModel!
    override func setUp() {
        super.setUp()
        vm = CheckoutViewModel(catalog: Catalog(coupons: [Coupon(code: "SAVE10", discountRate: 0.10)]))
    }
    func testTotalAppliesKnownCoupon() {
        vm.add(price: 100)
        vm.apply(couponCode: "SAVE10")
        XCTAssertEqual(vm.total, 90, accuracy: 0.001)
    }
}

// After — Swift Testing
@Suite("CheckoutViewModel")
@MainActor
struct CheckoutViewModelTests {
    @Test(arguments: [("SAVE10", 100.0, 90.0), ("NOPE", 100.0, 100.0)])
    func totalAppliesKnownCoupons(code: String, price: Double, expected: Double) {
        let vm = CheckoutViewModel(catalog: Catalog(coupons: [Coupon(code: "SAVE10", discountRate: 0.10)]))
        vm.add(price: price)
        vm.apply(couponCode: code)
        #expect(vm.total == expected)
    }
}

Three test methods collapsed into one parameterized test with two rows, the setup moved into an init that no one can forget to call, and the failure output now prints the actual values instead of a hand-written message.

8. When NOT to Use Swift Testing

The honest section. Swift Testing is not a universal replacement, and knowing where it stops saves you a painful rollback:

UI automation stays on XCUITest. XCUIApplication, XCUIElement queries, and launch arguments are all XCTest APIs, and there is no Swift Testing equivalent. UI test targets should remain XCTest; trying to write @Test UI tests buys you nothing.

Performance measurement has no counterpart. measure() and its metrics (CPU, memory, wall clock) don’t exist in Swift Testing. If you track regressions with measure, those suites stay in XCTest.

Benchmarks catch repeatable regressions before release; MetricKit production performance monitoring complements them with aggregate launch, hang, memory, CPU, and crash signals from shipped builds.

XCTest-only APIs. XCTContext and the XCTActivity machinery still have no Swift Testing equivalents. The other classics are covered now: confirmation(...) (since Xcode 16) replaces XCTestExpectation-style async tests, and Attachment.record(...) (since Xcode 26) replaces XCTAttachment. Tests that still reach for the remaining XCTest-only APIs are the last to migrate — if ever.

Tooling and CI requirements. Swift Testing requires the Xcode 16+ (Swift 6.0) toolchain — import Testing won’t compile on Xcode 15 CI images. Some third-party test-reporting dashboards and plugins that observe XCTest events also need updates; Swift Testing has its own event stream and ships an XCTest compatibility layer, but if your observability stack predates 2024, verify it before migrating everything.

App-hosted tests. Tests that run inside the app host — because they need your app’s bundles, plugins, or live UIApplication state — are hosted by XCTest-style infrastructure, and xcodebuild’s hosted-test workflow expects XCTest-shaped targets. Swift Testing runs fine in hosted scenarios, but the launch configuration, bundle setup, and failure reporting around hosted tests are still XCTest-shaped, so plan for it if that’s your setup.

The pragmatic line I recommend to teams: unit and integration tests of logic move to Swift Testing; UI, performance, and hosted tests stay in XCTest; and XCTest itself remains fully supported either way. Swift Testing being the default for new @Test functions doesn’t make your existing XCTestCase subclasses legacy code.

Conclusion

Swift Testing isn’t a syntax refresh — it’s a different model: functions over classes, macros over assertions, traits over boilerplate, parallelism over alphabetical serialism. The migration is incremental, the coexistence is seamless, and the payoff compounds — parameterized tests make edge cases data, #require removes whole categories of nil-handling bugs, and the failure output finally tells you what failed, not just that it failed.

Adopt it where it wins (logic, view models, data-driven suites), keep XCTest where it still owns the territory (UI, performance, hosted tests), and let the two frameworks coexist while you migrate. In a year, the XCTAssertEqual-and-subclass era will feel like the NSObject-subclassing era: something you remember, not something you miss.

Key Takeaways

  • Swift Testing is a new model, not a wrapper: @Test functions with compile-time discovery, #expect/#require macros, and no XCTestCase subclassing. Requires the Xcode 16+ (Swift 6.0) toolchain, and works in Swift 5 language mode too.
  • #expect reports actual values, #require unwraps or bailstry #require(optional) replaces XCTUnwrap and composes anywhere.
  • Parameterized tests via @Test(arguments:) turn edge cases into data with per-row results; tuples give you multi-input cases, and arguments must be Sendable in Swift 6 mode.
  • Traits replace boilerplate: .serialized, .disabled, .bug, .timeLimit, .tags, and .expectedToFail declare behavior next to the test; .expectedToFail even flags tests that unexpectedly start passing.
  • Async is first-class: async test functions, #expect(await ...), direct actor calls, and @MainActor suites — no XCTestExpectation juggling.
  • Migration is incremental: both frameworks coexist in one target and one test run. Translate assertions mechanically, watch for optional-comparison compile errors and ordering assumptions, and migrate logic suites first.
  • Don’t migrate everything: XCUITest, measure() performance tests, and CI toolchains without the new toolchain stay on XCTest. XCTest-only APIs that still lack equivalents (XCTContext, activities) stay too — while confirmation(...) (Xcode 16+) and Attachment.record(...) (Xcode 26+) cover the old expectation/attachment cases.