XCTest to Swift Testing Migration Playbook
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, 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, ST-0008, ST-0016, ST-0021). If CI uses an older Xcode than your machine, stick to the features that toolchain supports.
3. Step-by-Step Migration Order
- 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).
- Start with pure unit tests: model logic, parsers, formatters. Leave UI tests and performance tests where they are (section 10).
- 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).
- Replace XCTestExpectation with await, confirmation, or a continuation (section 7).
- Merge copy-pasted tests into parameterized tests (section 8).
- Replace skips and test-plan settings with traits: tags, conditions, time limits, bugs (section 9).
- Convert shared assertion helpers last, or temporarily keep separate XCTest and Swift Testing versions (section 11).
- 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:
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):
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):
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:
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().
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:
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:
// 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:
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.
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
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.
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 article and the swift-testing repository are the primary references.