# XCTest to Swift Testing Migration Playbook

Source: https://devdocs10.com/articles/swift/swift-xctest-to-swift-testing  
By Murat Bo · Published 2026-10-17 · DevDocs10 (https://devdocs10.com)

Swift Testing is the testing library that ships with Swift 6 toolchains and Xcode 16 and later. You write tests as `@Test` functions, check results with two macros, and use parameterized tests and traits in place of subclassing and naming conventions. This playbook moves an existing XCTest suite over step by step, with before/after code, an assertion mapping table, exact error messages, and a list of what should stay in XCTest.

> **Quick answer:** Migrate file by file inside the same test target. Both frameworks run side by side. Replace each `XCTestCase` subclass with a struct, `setUp`/`tearDown` with `init`/`deinit`, `testFoo()` methods with `@Test func foo()`, every `XCTAssert*` with `#expect`, and `XCTUnwrap` with `try #require`. Replace expectations with `async`/`await` or `confirmation`. Keep UI tests (`XCUIApplication`) and performance tests (`measure(metrics:)`) in XCTest, because Swift Testing has no equivalent for either.

> **Difficulty:** Intermediate
>
> **You'll understand this better if you know:** [unit testing with XCTest](/articles/swift/swift-unit-testing-xctest), Swift [async/await](/articles/swift/swift-async-await), and how test targets are set up in Xcode or SwiftPM.

## 1. What Changes

| Concept | XCTest | Swift Testing |
| --- | --- | --- |
| Import | `import XCTest` | `import Testing` |
| Test discovery | Methods named `test…` in an `XCTestCase` subclass | Any function marked `@Test` (global, or in a struct, actor or class) |
| Grouping | One class per group | Any type containing tests is a suite. `@Suite` adds a name and traits. |
| Assertions | About 40 `XCTAssert*` functions | `#expect` (records and continues) and `#require` (records and throws) |
| Failure output | The message you wrote | The expression with each sub-value captured, e.g. `cart.total → 12` |
| Setup / teardown | `setUp()` / `tearDown()` overrides | `init()` / `deinit`, with a fresh suite instance for every test |
| Data-driven tests | Loops inside one test | `@Test(arguments:)`, where each argument is a separate test case |
| Skip, tag, time limit, known bug | `XCTSkip`, test plans, `XCTExpectFailure` | Traits: `.disabled`, `.enabled(if:)`, `.tags`, `.timeLimit`, `.bug`, plus `withKnownIssue` |
| Execution | Synchronous test methods run on the main actor | In parallel by default, in-process, on arbitrary tasks |

The last row causes the most migration bugs. Tests that quietly depended on running one at a time on the main thread can become flaky. Section 13 covers how to deal with that.

## 2. Prerequisites and Versions

Swift Testing ships with the Swift 6 toolchain (Xcode 16 or later, or a Swift 6 toolchain on Linux or Windows). You can add `import Testing` to your existing test target without creating a new one. Some features arrived in later releases:

| Feature | Since |
| --- | --- |
| `@Test`, `#expect`, `#require`, `@Suite`, traits, parameterized tests, `confirmation` | Swift 6.0 |
| `#expect(throws:)` returns the caught error. Range `expectedCount` in `confirmation`. Test scoping traits. | Swift 6.1 |
| Exit tests (`#expect(processExitsWith:)`), attachments, raw-identifier test names | Swift 6.2 |
| `Test.cancel(_:)` (runtime skip) | Swift 6.3 |
| XCTest interoperability (section 11) | Swift 6.4 |

The versions come from the Swift Testing proposals ([ST-0006](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0006-return-errors-from-expect-throws.md), [ST-0008](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0008-exit-tests.md), [ST-0016](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0016-test-cancellation.md), [ST-0021](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0021-targeted-interoperability-swift-testing-and-xctest.md)). If CI uses an older Xcode than your machine, stick to the features that toolchain supports.

## 3. Step-by-Step Migration Order

1. **Check the toolchain** on your machine and in CI. Run `swift test` or the Xcode test action once to record a baseline (number of tests, all passing).
2. **Start with pure unit tests**: model logic, parsers, formatters. Leave UI tests and performance tests where they are (section 10).
3. **Convert one `XCTestCase` class per change**: switch to `import Testing`, turn the class into a struct, move `setUp` into `init`, add `@Test`, and map the assertions (sections 4–6).
4. **Replace `XCTestExpectation`** with `await`, `confirmation`, or a continuation (section 7).
5. **Merge copy-pasted tests** into parameterized tests (section 8).
6. **Replace skips and test-plan settings with traits**: tags, conditions, time limits, bugs (section 9).
7. **Convert shared assertion helpers** last, or temporarily keep separate XCTest and Swift Testing versions (section 11).
8. **Remove `import XCTest`** from every converted file, and check that the number of tests per run matches the baseline. Parameterized tests are reported once per argument.

## 4. Converting a Test Class

The code under test, shortened:

```swift
import Foundation

public enum CartError: Error, Equatable {
    case emptyName
    case invalidQuantity(Int)
}

public struct Item: Equatable, Sendable {
    public let name: String
    public let price: Decimal
}

public struct Cart: Sendable {
    public private(set) var items: [Item] = []
    public init() {}

    public mutating func add(_ name: String, price: Decimal, quantity: Int = 1) throws {
        guard !name.isEmpty else { throw CartError.emptyName }
        guard quantity > 0 else { throw CartError.invalidQuantity(quantity) }
        for _ in 0..<quantity { items.append(Item(name: name, price: price)) }
    }

    public var total: Decimal { items.reduce(0) { $0 + $1.price } }
    public func item(named name: String) -> Item? { items.first { $0.name == name } }
}

public func discount(for code: String) -> Double {
    switch code {
    case "SAVE10": 0.10
    case "SAVE25": 0.25
    default: 0
    }
}

public final class PriceService: Sendable {
    public init() {}

    public func fetchPrice(for name: String) async throws -> Decimal {
        try await Task.sleep(for: .milliseconds(10))
        return name == "Book" ? 12 : 5
    }

    public func observePrices(_ names: [String], onUpdate: @escaping @Sendable (String) -> Void) {
        for name in names { onUpdate(name) }
    }
}
```

**Before (XCTest):**

```swift
import XCTest
@testable import Cart

final class CartXCTests: XCTestCase {
    var cart: Cart!

    override func setUp() {
        super.setUp()
        cart = Cart()
    }

    override func tearDown() {
        cart = nil
        super.tearDown()
    }

    func testAddingItemIncreasesTotal() throws {
        try cart.add("Book", price: 12)
        XCTAssertEqual(cart.items.count, 1)
        XCTAssertEqual(cart.total, 12)
    }

    func testEmptyNameThrows() {
        XCTAssertThrowsError(try cart.add("", price: 1)) { error in
            XCTAssertEqual(error as? CartError, .emptyName)
        }
    }

    func testFindItem() throws {
        try cart.add("Pen", price: 2)
        let item = try XCTUnwrap(cart.item(named: "Pen"))
        XCTAssertEqual(item.price, 2)
    }

    func testDiscounts() {
        XCTAssertEqual(discount(for: "SAVE10"), 0.10, accuracy: 0.0001)
        XCTAssertEqual(discount(for: "SAVE25"), 0.25, accuracy: 0.0001)
        XCTAssertEqual(discount(for: "BOGUS"), 0, accuracy: 0.0001)
    }
}
```

**After (Swift Testing):**

```swift
import Testing
@testable import Cart

@Suite("Cart")
struct CartTests {
    var cart = Cart()          // a fresh value for every test: no setUp/tearDown needed

    @Test("Adding an item increases the total")
    mutating func addingItemIncreasesTotal() throws {
        try cart.add("Book", price: 12)
        #expect(cart.items.count == 1)
        #expect(cart.total == 12)
    }

    @Test mutating func emptyNameThrows() {
        #expect(throws: CartError.emptyName) {
            try cart.add("", price: 1)
        }
    }

    @Test mutating func findItem() throws {
        try cart.add("Pen", price: 2)
        let item = try #require(cart.item(named: "Pen"))
        #expect(item.price == 2)
    }

    @Test("Discount codes", arguments: [
        ("SAVE10", 0.10),
        ("SAVE25", 0.25),
        ("BOGUS", 0.0),
    ])
    func discounts(code: String, expected: Double) {
        #expect(abs(discount(for: code) - expected) < 0.0001)
    }
}
```

What changed:

- The implicitly unwrapped `var cart: Cart!` became a stored property with a default value. Swift Testing creates a new `CartTests` instance for every test, so each test starts with an empty cart.
- Tests that mutate stored properties of a struct suite must be `mutating`. Otherwise you get `cannot use mutating member on immutable value: 'self' is immutable`.
- The `test` prefix is gone. The string in `@Test("…")` is the display name shown in Xcode and in `swift test` output.
- Three near-identical discount assertions became one parameterized test with three separately reported cases.

When `#expect(cart.total == 13)` fails, the output shows the sub-values without any message from you: `Expectation failed: cart.total == 13` followed by `cart.total → 12`.

## 5. Assertion Mapping Table

| XCTest | Swift Testing |
| --- | --- |
| `XCTAssert(x)`, `XCTAssertTrue(x)` | `#expect(x)` |
| `XCTAssertFalse(x)` | `#expect(!x)` |
| `XCTAssertNil(x)` / `XCTAssertNotNil(x)` | `#expect(x == nil)` / `#expect(x != nil)` |
| `XCTAssertEqual(a, b)` / `XCTAssertNotEqual(a, b)` | `#expect(a == b)` / `#expect(a != b)` |
| `XCTAssertIdentical(a, b)` | `#expect(a === b)` |
| `XCTAssertGreaterThan(a, b)` and the other comparisons | `#expect(a > b)`, `#expect(a <= b)`, … |
| `XCTAssertEqual(a, b, accuracy: e)` | `#expect(abs(a - b) <= e)` (no built-in accuracy parameter; `isApproximatelyEqual(to:)` from swift-numerics is a common choice) |
| `XCTAssertThrowsError(try f())` | `#expect(throws: (any Error).self) { try f() }` |
| `XCTAssertThrowsError` + check the error | `#expect(throws: CartError.emptyName) { … }` (Equatable errors) or `let error = #expect(throws: CartError.self) { … }` (Swift 6.1+) |
| `XCTAssertNoThrow(try f())` | `#expect(throws: Never.self) { try f() }`, or just `try f()` in a throwing test |
| `try XCTUnwrap(x)` | `try #require(x)` |
| `continueAfterFailure = false` | `try #require(condition)` at the points where the test must stop |
| `XCTFail("message")` | `Issue.record("message")` |
| `XCTExpectFailure { … }` | `withKnownIssue { … }` |
| `try XCTSkipIf(cond)` / `XCTSkipUnless` | `@Test(.disabled(if: cond))` / `.enabled(if:)`, or `try Test.cancel("…")` at run time (Swift 6.3+) |
| `XCTestExpectation` + `wait`/`fulfillment(of:)` | `await` the async API, or `confirmation(expectedCount:)` |
| `measure { }`, `XCTMetric` | No equivalent. Keep these in XCTest. |

To check the error's details after a throw, use the returned error. This is the replacement for the trailing-closure form of `XCTAssertThrowsError`:

```swift
import Testing
@testable import Cart

@Test func invalidQuantityCarriesValue() throws {
    var cart = Cart()
    let error = #expect(throws: CartError.self) {
        try cart.add("Pen", price: 2, quantity: 0)
    }
    #expect(error == .invalidQuantity(0))
}
```

## 6. Setup and Teardown

Put the code from `setUp()` into the suite's `init`, which can be `async throws`. For teardown, use a `final class` or an actor as the suite and put the cleanup in `deinit`. `deinit` cannot be `async` or `throws`, unlike `tearDown()`.

```swift
import Testing
import Foundation
@testable import Cart

final class TempDirectory {
    let url: URL
    init() throws {
        url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
    }
    deinit { try? FileManager.default.removeItem(at: url) }
}

@Suite(.serialized)
final class ExportTests {
    let temp: TempDirectory

    init() async throws {            // replaces setUp() / setUpWithError()
        temp = try TempDirectory()
    }

    deinit {                         // replaces tearDown()
        // TempDirectory removes its folder in its own deinit
    }

    @Test func writesFile() throws {
        let file = temp.url.appendingPathComponent("cart.txt")
        try "Book".write(to: file, atomically: true, encoding: .utf8)
        #expect(FileManager.default.fileExists(atPath: file.path))
    }
}
```

XCTest's class-level `setUp()` (the `class func` override) has no direct equivalent. For expensive shared fixtures, use a `static let` on the suite (initialized lazily, once, in a thread-safe way) or a custom test scoping trait (Swift 6.1+).

## 7. Async Tests and Expectations

Async test methods carry over unchanged. Mark the function `async` and `await` inside it:

```swift
import Testing
@testable import Cart

@Test func fetchPrice() async throws {
    let price = try await PriceService().fetchPrice(for: "Book")
    #expect(price == 12)
}
```

For a callback that must fire a certain number of times, `confirmation` replaces `XCTestExpectation` with `expectedFulfillmentCount`:

```swift
// Before (XCTest)
func testObserverCalledTwice() {
    let expectation = expectation(description: "updates")
    expectation.expectedFulfillmentCount = 2
    PriceService().observePrices(["Book", "Pen"]) { _ in
        expectation.fulfill()
    }
    wait(for: [expectation], timeout: 1)
}

// After (Swift Testing)
@Test func observerCalledTwice() async {
    await confirmation(expectedCount: 2) { updated in
        PriceService().observePrices(["Book", "Pen"]) { _ in
            updated()
        }
    }
}
```

A confirmation does **not** wait. It checks the count when its closure returns. If the callback arrives later, for example on another queue, the test fails with `Confirmation was confirmed 0 times, but expected to be confirmed 1 time`. For callbacks that arrive asynchronously, turn the callback into an `async` value first:

```swift
import Testing
import Foundation

func fetchAll(_ names: [String], completion: @escaping @Sendable ([Decimal]) -> Void) {
    DispatchQueue.global().async { completion(names.map { _ in 1 }) }
}

@Test func callbackWithContinuation() async {
    let prices = await withCheckedContinuation { continuation in
        fetchAll(["Book", "Pen"]) { continuation.resume(returning: $0) }
    }
    #expect(prices.count == 2)
}
```

Use a range such as `expectedCount: 1...` (Swift 6.1+) where XCTest code set `assertForOverFulfill = false`, and `expectedCount: 0` to assert that something never happens.

## 8. Parameterized Tests

In XCTest, a loop over inputs is a single test. Failures from different inputs pile up in that one test, and a thrown `XCTUnwrap` error ends the loop early. A parameterized test runs each argument as its own case, in parallel, and Xcode can re-run a single failing case.

```swift
import Testing
@testable import Cart

// One collection: one case per element
@Test(arguments: ["Book", "Pen", "Lamp"])
func acceptsName(name: String) throws {
    var cart = Cart()
    try cart.add(name, price: 1)
    #expect(cart.items.count == 1)
}

// Two collections: every combination (2 × 2 = 4 cases)
@Test(arguments: ["Book", "Pen"], [1, 2])
func cartesian(name: String, quantity: Int) throws {
    var cart = Cart()
    try cart.add(name, price: 1, quantity: quantity)
    #expect(cart.items.count == quantity)
}

// Pairs: zip the collections (2 cases)
@Test(arguments: zip(["SAVE10", "SAVE25"], [0.10, 0.25]))
func zipped(code: String, expected: Double) {
    #expect(abs(discount(for: code) - expected) < 0.0001)
}
```

Passing two collections creates every combination, which is rarely what an XCTest table of input/expected pairs meant. Use `zip` or an array of tuples for pairs. Arguments must be `Sendable`. A test with `arguments:` but no parameters fails to compile with `Attribute 'Test' cannot specify arguments when used with function 'noParams()' because it does not take any`.

## 9. Traits: Skipping, Tags, Time Limits and Known Issues

```swift
import Testing
import Foundation
@testable import Cart

extension Tag {
    @Tag static var networking: Self
}

@Test(.disabled("Waiting for the new pricing API"))
func newPricing() {}

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] == nil, "Too slow for CI"))
func slowLocalOnly() {}

@Test(.tags(.networking), .timeLimit(.minutes(1)))
func fetchesPrice() async throws {
    #expect(try await PriceService().fetchPrice(for: "Pen") == 5)
}

@Test(.bug("https://example.com/issues/42", "Rounding is off by one cent"))
func knownRoundingBug() {
    withKnownIssue {
        #expect(Decimal(string: "0.1")! + Decimal(string: "0.2")! == Decimal(string: "0.31")!)
    }
}

// Runtime skip, the replacement for `throw XCTSkip(...)` (Swift 6.3+)
@Test func needsPricingServer() throws {
    guard ProcessInfo.processInfo.environment["PRICING_URL"] != nil else {
        try Test.cancel("PRICING_URL not set")
    }
}
```

In `swift test` output, a disabled test shows as `➜ Test newPricing() skipped: "Waiting for the new pricing API"`, a cancelled one as `was cancelled after … seconds: "PRICING_URL not set"`, and the known issue as `passed after … seconds with 1 known issue`. If the code inside `withKnownIssue` starts passing, the test fails with `Known issue was not recorded`, which reminds you to remove the wrapper.

## 10. What Stays in XCTest

- **UI tests.** `XCUIApplication` and the rest of the UI automation API exist only in XCTest, and UI test targets run under XCTest. Keep them as `XCTestCase` subclasses. In current SDKs `XCUIApplication` is main-actor isolated, so mark the test methods `@MainActor`.
- **Performance tests.** `measure(metrics:options:block:)` with `XCTClockMetric`, `XCTCPUMetric`, `XCTMemoryMetric`, `XCTStorageMetric` or `XCTApplicationLaunchMetric`, plus Xcode baselines, have no Swift Testing equivalent.
- **Tests written in Objective-C.** Swift Testing is a Swift-only API.

```swift
import XCTest
@testable import Cart

final class CartPerformanceTests: XCTestCase {
    func testTotalPerformance() throws {
        var cart = Cart()
        try cart.add("Book", price: 12, quantity: 10_000)

        let options = XCTMeasureOptions()
        options.iterationCount = 5

        measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()], options: options) {
            _ = cart.total
        }
    }
}

final class CheckoutUITests: XCTestCase {
    @MainActor
    func testCheckoutFlow() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-uiTesting"]
        app.launch()

        app.buttons["Add to Cart"].tap()
        app.buttons["Checkout"].tap()

        XCTAssertTrue(app.staticTexts["Order placed"].waitForExistence(timeout: 5))
    }
}
```

In practice, UI tests live in a separate UI test target. They are shown together here only for brevity.

## 11. Running Both Side by Side

One test target can contain both kinds of test, even in the same file if you import both modules. `swift test` runs XCTest first, then Swift Testing, and prints a summary for each. Xcode shows both in the Test navigator. Use `swift test --filter` with a regular expression to run a subset from either framework.

**Don't mix assertion APIs inside one test.** For years, an `XCTAssert` failure inside a `@Test` function was silently ignored, and the test passed. Swift 6.4 adds interoperability (ST-0021). In our tests on macOS with the Swift 6.4 toolchain and `swift test`, an XCTest helper that fails inside a `@Test` behaved as follows:

| Setup | Result |
| --- | --- |
| Toolchain older than 6.4, or `SWIFT_TESTING_XCTEST_INTEROP_MODE=none` | Test passes, and the failure is lost |
| 6.4 toolchain, `swift-tools-version` below 6.4 (`limited` mode) | Test passes "with 2 warnings": `Issue recorded` and `An API was misused`, with the hint `Replace XCTest API such as 'XCTAssert' with a Swift Testing equivalent such as '#expect'.` |
| 6.4 toolchain, `swift-tools-version: 6.4` (`complete` mode) | Test fails, and the hint is still reported as a warning |
| `SWIFT_TESTING_XCTEST_INTEROP_MODE=strict` | `Fatal error`: any XCTest API used inside Swift Testing stops the run |

The other direction works in all interop modes. A failing `#expect` inside an `XCTestCase` method fails that XCTest test. Because of this, a safe way to migrate shared helpers is to rewrite them with `#expect` first, so they work from both kinds of test. Set `strict` in CI after the migration so XCTest assertions cannot creep back into Swift Testing code.

## 12. Common Errors and Messages

| Message | Cause and fix |
| --- | --- |
| `Attribute 'Test' cannot be applied to a function within class 'MixedTests' because it is a subclass of 'XCTest', 'XCTestCase', or 'XCTestSuite'` | You added `@Test` to a method of an `XCTestCase` subclass. Convert the class to a struct first. |
| `errors thrown from here are not handled` (on `try #require`) | Mark the test function `throws`. |
| `cannot use mutating member on immutable value: 'self' is immutable` | A struct suite's test mutates a stored property. Add `mutating`, or use a local variable. |
| `instance method 'wait' is unavailable from asynchronous contexts; Use await fulfillment(of:timeout:enforceOrder:) instead` | XCTest waiting APIs inside an async `@Test`. Use `await`, `confirmation` or a continuation (section 7). |
| `Attribute 'Test' cannot specify arguments when used with function '…' because it does not take any` | Add parameters that match the argument collections. |
| `Confirmation was confirmed 0 times, but expected to be confirmed 1 time` (runtime) | The callback fired after the confirmation closure returned. Await it, or use a continuation. |
| `An API was misused` + `Replace XCTest API such as 'XCTAssert' …` (runtime warning) | An XCTest assertion ran inside a Swift Testing test. Convert it to `#expect`. |

## 13. Pitfalls

- **Hidden shared state breaks under parallel execution.** Singletons, `UserDefaults.standard`, files at fixed paths and static caches were safe when tests ran one at a time. Inject dependencies, use unique temporary directories, or add `.serialized` to the suite as a short-term fix.
- **Main-thread assumptions.** XCTest runs synchronous test methods on the main actor. Swift Testing does not. Mark tests that touch UI types or main-thread-only APIs `@MainActor`.
- **Expecting `init` to run once per suite.** It runs once per test, and once per argument in parameterized tests. Move expensive one-time work into a `static let`.
- **Treating `confirmation` like `wait(for:timeout:)`.** It has no timeout and does not wait (section 7).
- **Accidental Cartesian products.** `@Test(arguments: a, b)` creates every combination, not pairs.
- **Floating-point equality.** There is no `accuracy:` parameter. Compare with a tolerance explicitly.
- **Losing test coverage silently.** Converted helpers that still call `XCTAssert` may not fail Swift Testing tests on your toolchain (section 11). Compare test counts and run a deliberately failing test once to see that it fails.

## 14. Verification Checklist

- The total number of tests per run matches the baseline, counting each parameterized case as one.
- Making one assertion fail on purpose turns the run red, both locally and in CI.
- `git grep -n -E "XCTAssert|XCTUnwrap|XCTFail"` lists only XCTest-based files (UI, performance, and any not yet migrated).
- The suite passes several times in a row with parallel execution. Flaky tests point to shared state.
- CI runs both frameworks. Check that the CI logs show both the XCTest summary and the Swift Testing summary.
- After the migration, set `SWIFT_TESTING_XCTEST_INTEROP_MODE=strict` on a 6.4+ toolchain to keep XCTest assertions out of Swift Testing code.

## 15. Key Points

- Migrate incrementally: XCTest and Swift Testing can live in the same target and even the same file.
- `#expect` replaces the whole `XCTAssert*` family. `try #require` replaces `XCTUnwrap` and `continueAfterFailure = false`.
- Suites are usually structs. `init`/`deinit` replace setup and teardown, and every test gets a fresh instance.
- Parameterized tests and traits replace loops, skips and most test-plan configuration.
- Tests run in parallel and off the main actor by default, so watch for shared state and main-thread assumptions.
- UI tests and `measure`/`XCTMetric` performance tests stay in XCTest.
- Don't call XCTest assertions from Swift Testing tests. Before Swift 6.4 their failures are silently lost.

## 16. Final Summary

Moving from XCTest to Swift Testing is mostly mechanical at the level of single files. Swap the class for a struct, the overrides for `init` and `deinit`, and the assertion family for `#expect` and `#require`. The real gains come next: parameterized tests that report each input separately, traits that replace naming conventions and test-plan settings, and failure messages that show the values involved.

The risks are in behavior, not syntax. Parallel execution exposes shared state, tests no longer run on the main actor, and mixing assertion APIs can hide failures on older toolchains. Convert one class at a time, compare test counts, and keep UI and performance tests in XCTest. Apple's [Migrating a test from XCTest](https://developer.apple.com/documentation/testing/migratingfromxctest) article and the [swift-testing repository](https://github.com/swiftlang/swift-testing) are the primary references.
