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.0Testing 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:
- Use
@Suitewith instance properties (each test gets its own instance) - 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.