Skip to content
Back to Blog
·5 min read

Migrating from XCTest to Swift Testing

Convert XCTest unit tests to Swift Testing: @Test, #expect and #require, suites, parameterized tests, traits, async confirmations, and what stays in XCTest.

Swift Testing replaces XCTestCase classes with plain functions marked @Test, and the whole XCTAssert family with two macros: #expect (record a failure and keep going) and #require (throw and stop). Setup and teardown become init() and deinit. You can move one file at a time, because both frameworks can live in the same test target, even the same file. UI tests and performance tests stay in XCTest. To convert a file of XCTest code in your browser, try our XCTest to Swift Testing converter.

Swift Testing comes with Swift 6.0 and Xcode 16, and Apple's Xcode testing page says Xcode "continues to include XCTest to facilitate writing UI tests".

Import and convert the class

Import Testing instead of XCTest. If a file still holds tests from both frameworks, import both. Apple's migration article says a single source file can contain tests written with either.

An XCTestCase subclass becomes a suite: remove the XCTestCase conformance. Apple recommends a struct or an actor over a class where you can, because the compiler can then check concurrency safety better. Marking the type with @Suite is optional; you add it to give the suite a display name or traits.

A test is any function with @Test. It does not need a test prefix, and it can be async, throws, or isolated to a global actor. One difference to watch: XCTest runs synchronous test methods on the main actor, while Swift Testing runs test functions on an arbitrary task. If the test touches UI-bound code, add @MainActor.

Each test method on a suite type runs on its own fresh instance of the type, so state never leaks from one test into the next.

Before and after

Here is an XCTest class with the usual setup, teardown, an equality check, a throwing call and an unwrap:

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 testAddIncreasesCount() {
        cart.add("tea")
        XCTAssertEqual(cart.items.count, 1)
        XCTAssertFalse(cart.items.isEmpty)
    }

    func testUnknownItemThrows() {
        XCTAssertThrowsError(try cart.price(of: "gold"))
    }

    func testLookup() throws {
        let price = try XCTUnwrap(cart.lookup("tea"))
        XCTAssertEqual(price, 3)
    }
}

And the same tests in Swift Testing. A stored property with a value stands in for setUp, because every test gets a new instance:

import Testing
@testable import Cart

@Suite("Cart")
struct CartTests {
    let cart = Cart()

    @Test func addIncreasesCount() {
        cart.add("tea")
        #expect(cart.items.count == 1)
        #expect(!cart.items.isEmpty)
    }

    @Test func unknownItemThrows() {
        #expect(throws: CartError.self) { try cart.price(of: "gold") }
    }

    @Test func lookup() throws {
        let price = try #require(cart.lookup("tea"))
        #expect(price == 3)
    }
}

Setup and teardown

Apple's rule: implement init() and, if needed, deinit instead of setUp() and tearDown(). init may be async and throws. If you need teardown, declare the suite as a class or an actor, because only those can have a deinit.

final class Scratch: @unchecked Sendable {
    static nonisolated(unsafe) var live = 0
    init() { Scratch.live += 1 }
    deinit { Scratch.live -= 1 }
}

@Suite(.serialized)
final class ScratchTests {
    let scratch = Scratch()
    deinit { /* clean up files or fixtures here */ }

    @Test func isLive() { #expect(Scratch.live >= 1) }
}

Assertion mapping

This is the table from Apple's migration article, trimmed to the rows you will meet most.

XCTestSwift Testing
XCTAssert(x), XCTAssertTrue(x)#expect(x)
XCTAssertFalse(x)#expect(!x)
XCTAssertNil(x)#expect(x == nil)
XCTAssertNotNil(x)#expect(x != nil)
XCTAssertEqual(x, y)#expect(x == y)
XCTAssertNotEqual(x, y)#expect(x != y)
XCTAssertIdentical(x, y)#expect(x === y)
XCTAssertGreaterThan(x, y)#expect(x > y)
XCTAssertLessThan(x, y)#expect(x < y)
XCTAssertThrowsError(try f())#expect(throws: (any Error).self) { try f() }
XCTAssertNoThrow(try f())#expect(throws: Never.self) { try f() }
try XCTUnwrap(x)try #require(x)
XCTFail("...")Issue.record("...")

There is no direct counterpart of XCTAssertEqual(_:_:accuracy:). Apple points to isApproximatelyEqual() from the swift-numerics package for comparing numbers within a tolerance. #require is also the replacement for continueAfterFailure = false: it throws a Swift error, and other tests keep running.

Parameterized tests

Where an XCTest loop hides which input failed, Swift Testing runs one test case per input and reports each one. Pass a collection to arguments:. Apple's documentation also shows enums that conform to CaseIterable (pass allCases, so a new case is tested without edits) and integer ranges.

@Test("Known prices", arguments: [("tea", 3), ("cake", 5)])
func price(item: String, expected: Int) throws {
    #expect(try cart.price(of: item) == expected)
}

By default, the cases of a parameterized test run in parallel with each other.

Traits

Traits are extra arguments to @Test or @Suite. A trait on a suite is inherited by its tests.

import Foundation
import Testing

extension Tag {
    @Tag static var pricing: Self
}

@Suite(.tags(.pricing))
struct TraitTests {
    @Test(.disabled("Waiting for the new price list"))
    func futurePrices() { #expect(Bool(false)) }

    @Test(.bug("https://example.com/issues/42", "Price rounds wrong"), .timeLimit(.minutes(1)))
    func slowPath() { #expect(Cart().lookup("tea") == 3) }

    @Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_SLOW"] == nil))
    func enabledByDefault() { #expect(true) }
}
  • .disabled(_:) skips a test with a comment, and .enabled(if:) runs it only when the condition holds. This replaces XCTSkip, XCTSkipIf and XCTSkipUnless.
  • .bug(_:_:) links a test to an issue tracker entry.
  • .tags(_:) takes Tag values you declare with the @Tag macro, to group and filter tests.
  • .timeLimit(_:) fails a test that runs too long. It is available from iOS 16, macOS 13 and watchOS 9.
  • .serialized makes the tests in a suite run one after another. Swift Testing runs tests in parallel by default, while XCTest runs the tests of a class one at a time, so add .serialized if your tests share global state.

For an expected failure, withKnownIssue { ... } takes the place of XCTExpectFailure. Apple notes that the closure-less form of XCTExpectFailure has no direct equivalent; wrap the test body in withKnownIssue instead.

Async callbacks with confirmation

When you can, await the async function directly, or wrap a completion handler in a continuation. Apple's article recommends this over expectations. For events that arrive on their own, such as a handler that fires later, use confirmation(_:expectedCount:), the counterpart of XCTestExpectation.

@Test func changeHandlerFires() async {
    await confirmation("onChange called") { changed in
        cart.onChange = { changed() }
        cart.add("tea")
    }
}

@Test func changeHandlerFiresTwice() async {
    await confirmation(expectedCount: 2) { changed in
        cart.onChange = { changed() }
        cart.add("tea")
        cart.add("cake")
    }
}

Two differences from expectations. A confirmation does not block or wait: the event has to happen before confirmation returns, or an issue is recorded. And the default is exactly one confirmation, like XCTestExpectation. For "at least" you can pass a range with a lower bound, for example 1... or 0 ..< 100; expectedCount: 0 checks that something never happens.

What stays in XCTest

  • UI tests. Apple's Xcode testing page says Xcode keeps XCTest for UI tests that control the app's interface. Apple's guide to adding tests also describes UI tests as XCTestCase subclasses.
  • Performance tests. Apple's performance-testing guide says they use the XCTest framework, with measure calls inside an XCTestCase. Swift Testing has no counterpart.
  • Anything that needs XCTest-only APIs, such as XCTAssertEqual with an accuracy argument.

Run both side by side

Apple's guide says Swift Testing and XCTest can build from the same test target and coexist inside one test bundle, so you do not need a new target. Convert files as time allows.

Helpers can be shared. With interoperability, an XCTAssert call inside a helper that a Swift Testing test calls is reported as a failure, and a #expect inside a helper called from an XCTest method fails that method. The default mode depends on your toolchain: with a toolchain older than 6.4 it is none; with 6.4 or later it is limited, or complete when the package's swift-tools-version is 6.4 or later. Set SWIFT_TESTING_XCTEST_INTEROP_MODE to choose a mode yourself. The safest habit is to move shared helpers to #expect first, which Apple's article describes as a way to migrate your test infrastructure at your own pace:

func assertUnique(_ elements: [Int]) {
    #expect(Set(elements).count == elements.count)
}

@Test func uniqueElements() { assertUnique([1, 2, 3]) }

final class UniqueXC: XCTestCase {
    func testUnique() { assertUnique([1, 2, 3]) }
    func testMeasure() { measure { _ = (0..<1000).map { $0 * 2 } } }
}

Sources

All read on 1 November 2026.

Apple's APIs and defaults change; check the pages above for current details.

Written by

Building local-first Mac & iOS productivity apps at Obelisk Club. About the author →