Swift Testing Framework: The New @Test API in Xcode 16

Swift Testing Framework: The New @Test API in Xcode 16

Apple introduced the Swift Testing framework in Xcode 16 as a modern replacement for XCTest. It uses Swift macros, structured concurrency, and a cleaner syntax that feels native to Swift — unlike XCTest, which was ported from Objective-C.

This guide covers everything in the new framework: @Test, @Suite, #expect, parameterized tests, and how to migrate from XCTest.

What's Different From XCTest

Feature XCTest Swift Testing
Test marker Class XCTestCase + test prefix @Test attribute
Assertions XCTAssert* functions #expect and #require macros
Setup/teardown setUp() / tearDown() init() / deinit
Parameterized tests None (requires loops) Native @Test(arguments:)
Async support async + custom expectation Native async/await
Parallel tests Limited Default for @Test
Error handling XCTAssertThrowsError #expect(throws:)

Installation

Swift Testing ships with Xcode 16 — no installation needed. It requires:

  • Xcode 16+
  • iOS 13+, macOS 10.15+, tvOS 13+, watchOS 6+

For Swift Package Manager, add it as a dependency:

// Package.swift
.testTarget(
    name: "MyAppTests",
    dependencies: [
        .product(name: "Testing", package: "swift-testing"),
        "MyApp"
    ]
)

Basic @Test Functions

The simplest test is a free function marked with @Test:

import Testing
@testable import MyApp

@Test func userNameNotEmpty() {
    let user = User(id: "1", name: "Alice")
    #expect(!user.name.isEmpty)
}

@Test func cartTotalCalculation() {
    let cart = ShoppingCart()
    cart.add(item: Item(price: 10.0))
    cart.add(item: Item(price: 25.0))
    #expect(cart.total == 35.0)
}

No class required. No test prefix required. Just @Test.

#expect: The New Assertion Macro

#expect replaces all the XCTAssert* variants:

// XCTest equivalents → Swift Testing
XCTAssertEqual(a, b)           → #expect(a == b)
XCTAssertNotNil(x)             → #expect(x != nil)
XCTAssertTrue(condition)       → #expect(condition)
XCTAssertFalse(condition)      → #expect(!condition)
XCTAssertGreaterThan(a, b)     → #expect(a > b)
XCTAssertNil(x)                → #expect(x == nil)

// For fatal failures that stop the test:
XCTAssertNotNil(x)             → try #require(x != nil)

When #expect fails, the macro captures the entire expression and shows exactly what went wrong:

▽ Expectation failed: (cart.total → 34.5) == 35.0

#require for Early Exits

#require is like #expect but throws on failure, stopping the test:

@Test func itemsAddedToCart() throws {
    let cart = ShoppingCart()
    
    cart.add(item: Item(name: "Book", price: 15.0))
    
    let firstItem = try #require(cart.items.first)  // Stops if nil
    #expect(firstItem.name == "Book")
    #expect(firstItem.price == 15.0)
}

@Suite: Grouping Tests

@Suite groups related tests with shared setup:

@Suite("Shopping Cart Tests")
struct CartTests {
    var cart: ShoppingCart
    
    init() {
        // Runs before each test (like setUp)
        cart = ShoppingCart()
    }
    
    @Test func emptyCartHasZeroTotal() {
        #expect(cart.total == 0.0)
    }
    
    @Test func addingItemIncreasesTotal() {
        cart.add(item: Item(price: 10.0))
        #expect(cart.total == 10.0)
    }
    
    @Test func removingItemDecreasesTotal() throws {
        cart.add(item: Item(id: "1", price: 10.0))
        try cart.remove(itemId: "1")
        #expect(cart.total == 0.0)
    }
}

init() and deinit replace setUp() and tearDown(). Each test gets a fresh CartTests instance.

Parameterized Tests — The Best New Feature

XCTest required loops and custom logic for parameterized tests. Swift Testing has first-class support:

@Test("Price calculations", arguments: [
    (qty: 1, price: 10.0, expected: 10.0),
    (qty: 3, price: 10.0, expected: 30.0),
    (qty: 0, price: 10.0, expected: 0.0),
    (qty: 1, price: 0.0, expected: 0.0),
])
func priceWithQuantity(qty: Int, price: Double, expected: Double) {
    let item = Item(price: price)
    let total = item.totalPrice(quantity: qty)
    #expect(total == expected)
}

Each argument combination runs as a separate test case — independently reported, independently failed:

▽ Price calculations
  ✓ qty: 1, price: 10.0, expected: 10.0
  ✓ qty: 3, price: 10.0, expected: 30.0
  ✓ qty: 0, price: 10.0, expected: 0.0
  ✗ qty: 1, price: 0.0, expected: 0.0
    Expectation failed: (total → 0.01) == 0.0

Testing Errors

@Test func invalidItemThrowsError() {
    #expect(throws: ValidationError.invalidPrice) {
        try Item(price: -5.0)
    }
}

@Test func anyErrorThrown() {
    #expect(throws: (any Error).self) {
        try loadFile(path: "/nonexistent")
    }
}

@Test func noErrorThrown() {
    #expect(throws: Never.self) {
        try Item(price: 10.0)
    }
}

Async Tests

@Test functions can be async natively:

@Test func fetchUserFromAPI() async throws {
    let service = UserService(client: MockHTTPClient())
    
    let user = try await service.fetchUser(id: "user-1")
    
    #expect(user.id == "user-1")
    #expect(!user.name.isEmpty)
}

No XCTestExpectation or waitForExpectations — just async/await.

Disabling and Skipping Tests

// Skip unconditionally
@Test(.disabled("Not implemented yet")) 
func featureNotYetBuilt() {
    // ...
}

// Skip on specific condition
@Test(.disabled(if: ProcessInfo.processInfo.environment["CI"] == "true", "Flaky on CI"))
func flakyTest() {
    // ...
}

// Only on certain OS
@Test
@available(iOS 17, *)
func swiftDataTest() {
    // Only runs on iOS 17+
}

Mixing XCTest and Swift Testing

You can use both in the same target during migration:

// OldTests.swift — stays as XCTest
class OldUserTests: XCTestCase {
    func testUserCreation() {
        let user = User(id: "1", name: "Alice")
        XCTAssertEqual(user.name, "Alice")
    }
}

// NewTests.swift — uses Swift Testing
@Test func userCreation() {
    let user = User(id: "1", name: "Alice")
    #expect(user.name == "Alice")
}

Both run under the same test scheme in Xcode.

Migration Guide: XCTest → Swift Testing

Simple assertion

// Before
func testTotalIsCorrect() {
    XCTAssertEqual(cart.total, 35.0)
}

// After
@Test func totalIsCorrect() {
    #expect(cart.total == 35.0)
}

Setup and teardown

// Before
class CartTests: XCTestCase {
    var cart: ShoppingCart!
    
    override func setUp() {
        super.setUp()
        cart = ShoppingCart()
    }
}

// After
@Suite struct CartTests {
    var cart: ShoppingCart
    
    init() {
        cart = ShoppingCart()
    }
}

Parameterized tests

// Before — manual loop
func testPrices() {
    let cases: [(Int, Double)] = [(1, 10.0), (2, 20.0), (3, 30.0)]
    for (qty, expected) in cases {
        XCTAssertEqual(Item(price: 10.0).totalPrice(quantity: qty), expected)
    }
}

// After — native
@Test(arguments: [(1, 10.0), (2, 20.0), (3, 30.0)])
func prices(qty: Int, expected: Double) {
    #expect(Item(price: 10.0).totalPrice(quantity: qty) == expected)
}

Parallel Test Execution

Swift Testing runs @Test functions in parallel by default. If tests share mutable state, either:

  1. Use @Suite with instance properties (each test gets its own instance)
  2. Mark the suite as @Suite(.serialized) to force serial execution
@Suite(.serialized)  // Run tests in this suite one at a time
struct DatabaseTests {
    // Tests that write to a shared database
}

Conclusion

The Swift Testing framework is a genuine improvement over XCTest: cleaner syntax, native parameterization, better error messages from #expect, and first-class async support. If you're targeting Xcode 16 and iOS 13+, there's no reason to write new XCTest code. Migrate existing tests gradually — both frameworks coexist in the same target during transition.

Read more

Start now free