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:criticalOr 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— useinit/deinitin@Suitestructs instead- UI testing —
XCUIApplication,XCUIElementremain XCTest-only - Performance testing —
measure {}blocks are XCTest-only XCTestCasesubclassing — 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
- New tests go in Swift Testing immediately. Don't add to XCTest files.
- Migrate file by file when you touch an existing test file.
- Keep XCUITest in XCTest — there's no Swift Testing equivalent yet.
- Migrate performance tests last —
measure {}has no replacement. - 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@Suitenesting = organized test hierarchies- Native parameterization = no more manual loops
- Tags = flexible filtering without naming hacks
- Async built in = no
XCTestExpectationboilerplate - 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.