Swift Testing Framework: @Test Macros, #expect, and Migrating from XCTest in Xcode 16

Swift Testing Framework: @Test Macros, #expect, and Migrating from XCTest in Xcode 16

Apple's swift-testing framework (available in Xcode 16, Swift 6) replaces XCTest's 20-year-old Objective-C heritage with a modern, macro-based API. Instead of XCTAssertEqual, you write #expect(a == b). Instead of func testSomething(), you write @Test func something(). Parameterized tests, parallel execution, and expressive failure messages are built in. This guide covers the full API, migration patterns, and what can't be migrated yet.

Why Swift Testing

XCTest was designed for Objective-C in 2013. It has served iOS development well, but it shows its age:

  • Test methods must start with test — a naming convention enforced by the runtime
  • Assertion failures print XCTAssertEqual failed: ("42") is not equal to ("43") — not very readable
  • No native parameterized tests — you loop manually
  • Test classes must inherit from XCTestCase
  • No test tagging or filtering by metadata

Swift Testing (apple/swift-testing, now bundled with Xcode 16) addresses all of these with a macro-based API designed specifically for Swift.

Requirements

  • Xcode 16 or later
  • Swift 6 (included with Xcode 16)
  • iOS 16+ / macOS 13+ deployment targets (the framework can test code targeting earlier platforms, but the framework itself requires Swift 6)

Your First Test

import Testing

@Test func additionIsCommutative() {
    let a = 3
    let b = 5
    #expect(a + b == b + a)
}

That's it. No class, no inheritance from XCTestCase, no test prefix requirement. The @Test macro marks a function as a test.

Compare to XCTest:

import XCTest

final class MathTests: XCTestCase {
    func testAdditionIsCommutative() {
        let a = 3
        let b = 5
        XCTAssertEqual(a + b, b + a)
    }
}

#expect — The Core Assertion Macro

#expect takes any Bool expression. On failure, it uses macros to capture the full expression and display it:

@Test func userValidation() {
    let user = User(name: "Alice", age: 17)
    #expect(user.age >= 18)
    // Failure: Expectation failed: (user.age → 17) >= 18
}

The error message includes the actual value of user.age — no need to write XCTAssertGreaterThanOrEqual(user.age, 18, "Expected adult user").

Checking Equality

#expect(result == expectedValue)
#expect(array.count == 3)
#expect(string.hasPrefix("Hello"))

Checking Optionals

let value: Int? = fetchValue()
#expect(value != nil)

// Unwrap and continue:
let unwrapped = try #require(value)
// If value is nil, test stops here with a clear failure
#expect(unwrapped > 0)

#require is the throwing equivalent — it unwraps or throws, stopping the test cleanly instead of crashing.

Checking Throws

@Test func invalidInputThrows() {
    #expect(throws: ValidationError.emptyName) {
        try validateUser(name: "")
    }
}

// Or match any error of a type:
@Test func networkErrorThrows() {
    #expect(throws: URLError.self) {
        try performNetworkRequest(url: nil)
    }
}

@Suite — Grouping Tests

You can group related tests into a @Suite struct (or class, or actor):

@Suite("User Authentication")
struct AuthTests {
    @Test func loginWithValidCredentials() { ... }
    @Test func loginWithInvalidPassword() { ... }
    @Test func logoutClearsSession() { ... }
}

Suites can be nested:

@Suite("Cart")
struct CartTests {
    @Suite("Adding Items")
    struct AddingItemsTests {
        @Test func addSingleItem() { ... }
        @Test func addDuplicateItem() { ... }
    }

    @Suite("Checkout")
    struct CheckoutTests {
        @Test func checkoutWithEmptyCart() { ... }
    }
}

Nesting appears in Xcode's test navigator as a tree, making large test suites navigable.

Parameterized Tests

This is where Swift Testing pulls ahead significantly. XCTest has no built-in parameterization — you write for loops or use third-party libraries. Swift Testing has it natively:

@Test(arguments: ["", "   ", "\t\n"])
func emptyOrWhitespaceInputIsInvalid(input: String) {
    #expect(throws: ValidationError.self) {
        try validateUsername(input)
    }
}

This generates three separate test cases, each displayed individually in Xcode's test navigator with the argument value in the name.

Multi-argument parameterization:

@Test(arguments: zip(
    [1, 2, 3],
    [1, 4, 9]
))
func squaresAreCorrect(input: Int, expected: Int) {
    #expect(input * input == expected)
}

Or with product (every combination):

@Test(arguments: ["USD", "EUR", "GBP"], [100, 500, 1000])
func formatsAllCurrencyAmounts(currency: String, amount: Int) {
    let formatted = CurrencyFormatter.format(amount: amount, currency: currency)
    #expect(!formatted.isEmpty)
}
// Generates 9 test cases (3 currencies × 3 amounts)

Tags — Filtering and Categorizing Tests

Declare tags as extensions on Tag:

extension Tag {
    @Tag static var network: Self
    @Tag static var slow: Self
    @Tag static var critical: Self
}

Apply them to tests:

@Test(.tags(.network, .slow))
func fetchUserProfileFromAPI() async throws { ... }

@Test(.tags(.critical))
func passwordHashingIsSecure() { ... }

Run only tagged tests from the command line:

swift test --filter tag:critical

Or exclude slow tests:

swift test --filter <span class="hljs-string">"not tag:slow"

Async Tests

Async support is built in — just mark the function async:

@Test func fetchRemoteConfig() async throws {
    let config = try await ConfigService.shared.fetch()
    #expect(config.featureFlags.contains("newDashboard"))
}

No XCTestExpectation needed for async/await patterns.

Parallel Execution

Swift Testing runs tests in parallel by default. If a test is not safe to run in parallel (e.g., it modifies shared global state), mark it serial:

@Test(.serialized)
func testThatNeedsExclusiveAccess() { ... }

Or mark a whole suite as serialized:

@Suite(.serialized)
struct DatabaseMigrationTests { ... }

Migrating from XCTest

What you CAN migrate

XCTest Swift Testing equivalent
class FooTests: XCTestCase @Suite struct FooTests
func testBar() @Test func bar()
XCTAssertEqual(a, b) #expect(a == b)
XCTAssertNil(x) #expect(x == nil)
XCTAssertNotNil(x) #expect(x != nil)
XCTAssertTrue(condition) #expect(condition)
XCTAssertFalse(condition) #expect(!condition)
XCTAssertThrowsError(try f()) #expect(throws: Error.self) { try f() }
XCTUnwrap(optional) try #require(optional)
XCTFail("message") Issue.record("message")

What you CANNOT migrate yet

Some XCTest features have no Swift Testing equivalent (as of Xcode 16.3):

  • setUp/tearDown — use init/deinit in @Suite structs instead
  • UI testingXCUIApplication, XCUIElement remain XCTest-only
  • Performance testingmeasure {} blocks are XCTest-only
  • XCTestCase subclassing — Swift Testing doesn't use class inheritance

You can run XCTest and Swift Testing side by side in the same target — no need to migrate all at once.

setUp/tearDown Pattern

// XCTest (before):
final class DatabaseTests: XCTestCase {
    var db: Database!
    override func setUp() { db = Database(inMemory: true) }
    override func tearDown() { db = nil }

    func testInsert() { ... }
}

// Swift Testing (after):
@Suite struct DatabaseTests {
    let db: Database

    init() { db = Database(inMemory: true) }
    deinit { /* cleanup if needed */ }

    @Test func insert() { ... }
}

Each test function gets a fresh instance of the @Suite struct, so init/deinit run per test — equivalent to setUp/tearDown.

Running Swift Testing from the CLI

# Run all tests (XCTest + Swift Testing)
swift <span class="hljs-built_in">test

<span class="hljs-comment"># Run only Swift Testing tests matching a name
swift <span class="hljs-built_in">test --filter <span class="hljs-string">"AuthTests"

<span class="hljs-comment"># Run a specific test by name
swift <span class="hljs-built_in">test --filter <span class="hljs-string">"AuthTests/loginWithValidCredentials"

<span class="hljs-comment"># Run tagged tests
swift <span class="hljs-built_in">test --filter <span class="hljs-string">"tag:critical"

Xcode's test navigator also supports running, filtering, and re-running individual Swift Testing cases.

Practical Migration Strategy

  1. New tests go in Swift Testing immediately. Don't add to XCTest files.
  2. Migrate file by file when you touch an existing test file.
  3. Keep XCUITest in XCTest — there's no Swift Testing equivalent yet.
  4. Migrate performance tests lastmeasure {} has no replacement.
  5. Use tags to mark migrated tests so you can filter and run them separately.

Summary

Swift Testing is a significant improvement over XCTest for pure Swift unit and integration testing:

  • @Test + #expect = clean, readable tests with expressive failure messages
  • @Suite nesting = organized test hierarchies
  • Native parameterization = no more manual loops
  • Tags = flexible filtering without naming hacks
  • Async built in = no XCTestExpectation boilerplate
  • Parallel by default = faster test runs

Adopt it for all new tests today. Migrate XCTest files opportunistically. Keep XCTest for UI automation and performance measurement until Apple ships Swift Testing equivalents.

Read more