The Complete Guide to Swift Testing: From First Test to Advanced Patterns

A comprehensive guide to Swift Testing covering @Test macros, #expect and #require assertions, parameterized tests, tags, traits, exit tests, and a practical migration path from XCTest.

If you've been writing tests for Swift apps, you've almost certainly been using XCTest — Apple's battle-tested framework that's been around since the Objective-C days. It works, sure. But let's be honest: it hasn't aged gracefully. Forty-plus assertion functions, mandatory class inheritance, no parameterized tests, and an architecture that predates Swift by years.

It was time for something better.

Swift Testing is that something. Introduced at WWDC 2024 and shipped with Swift 6 and Xcode 16, it's a ground-up reimagining of how testing should work in Swift. Built on macros, designed for concurrency, and expressively minimal in syntax — it honestly feels like the testing framework Swift always deserved. And with Swift 6.2 and Xcode 26 landing new features like exit tests, ranged confirmations, and test scoping traits, the framework has matured into something genuinely powerful.

In this guide, we'll walk through Swift Testing from your very first test all the way to advanced patterns that'll transform how you write and organize tests. Whether you're starting fresh or migrating from XCTest, this is your comprehensive reference.

Getting Started: Your First Swift Test

The best way to understand Swift Testing is to just write a test. If you're using Xcode 16 or later with a Swift 6+ toolchain, Swift Testing is already available — no package dependencies needed. That alone is a nice change of pace.

Let's say you have a simple Calculator struct:

struct Calculator {
    func add(_ a: Int, _ b: Int) -> Int {
        a + b
    }

    func divide(_ a: Double, _ b: Double) throws -> Double {
        guard b != 0 else {
            throw CalculatorError.divisionByZero
        }
        return a / b
    }
}

enum CalculatorError: Error {
    case divisionByZero
}

Here's how you test it with Swift Testing:

import Testing

@Test func additionProducesCorrectResult() {
    let calculator = Calculator()
    #expect(calculator.add(2, 3) == 5)
}

That's it. No class inheritance. No XCTestCase subclass. No test prefix requirement. Just a free function annotated with @Test and an #expect macro for your assertion. The framework discovers it automatically.

Compare that to the XCTest equivalent:

import XCTest

class CalculatorTests: XCTestCase {
    func testAdditionProducesCorrectResult() {
        let calculator = Calculator()
        XCTAssertEqual(calculator.add(2, 3), 5)
    }
}

The Swift Testing version is shorter, more expressive, and doesn't force you into a class hierarchy. But the real power goes much deeper than just syntax.

The Two Core Macros: #expect and #require

XCTest ships with over forty assertion functions: XCTAssertEqual, XCTAssertNil, XCTAssertGreaterThan, XCTAssertTrue, XCTAssertThrowsError... the list goes on. Swift Testing replaces all of them with just two macros.

Two. That's it.

#expect — The Soft Assertion

#expect evaluates a Boolean expression and records a failure if it's false, but the test continues running. This is the assertion you'll reach for most often:

@Test func userValidation() {
    let user = User(name: "Alice", age: 30, email: "[email protected]")

    // All of these are checked, even if earlier ones fail
    #expect(user.name == "Alice")
    #expect(user.age >= 18)
    #expect(user.email.contains("@"))
    #expect(user.isActive)
}

Because #expect doesn't halt execution, you see all failures in a single test run — not just the first one. If you've ever had that frustrating cycle of fix one assertion, re-run, hit the next failure, fix it, re-run again... you know exactly why this matters.

The macro also captures the actual evaluated values in its diagnostic output. When #expect(user.age >= 18) fails, the output shows you the actual value of user.age. No more guessing what went wrong.

#require — The Hard Assertion

#require is the gatekeeper. If the condition fails, it throws an error and immediately stops the test. Use it when continuing after a failure would be meaningless (or would just cause a crash):

@Test func parseUserFromJSON() throws {
    let json = """
    {"name": "Bob", "age": 25}
    """
    let data = json.data(using: .utf8)

    // If this fails, there's no point continuing
    let unwrappedData = try #require(data)

    let user = try JSONDecoder().decode(User.self, from: unwrappedData)
    #expect(user.name == "Bob")
    #expect(user.age == 25)
}

Notice how #require doubles as a safe unwrapper — it's the direct replacement for XCTUnwrap. If data is nil, the test fails immediately with a clear diagnostic instead of crashing on the next line. Much nicer.

Testing for Errors

Both macros support error-throwing expressions natively. You can verify that code throws a specific error type:

@Test func divisionByZeroThrows() {
    let calculator = Calculator()

    #expect(throws: CalculatorError.divisionByZero) {
        try calculator.divide(10, by: 0)
    }
}

// Or check for any error of a specific type
@Test func invalidInputThrowsError() {
    #expect(throws: ValidationError.self) {
        try validateInput("")
    }
}

// Or verify no error is thrown
@Test func validInputSucceeds() {
    #expect(throws: Never.self) {
        try validateInput("hello")
    }
}

This is far more readable than XCTest's XCTAssertThrowsError with its nested closure pattern. I remember the first time I saw that closure-in-a-closure structure — it took me a minute to figure out what was going on.

Organizing Tests with @Suite

Free-function tests work fine for small projects, but real codebases need organization. The @Suite macro lets you group related tests into suites — and unlike XCTest, suites can be structs, classes, or even actors:

@Suite("Calculator Operations")
struct CalculatorTests {
    let calculator = Calculator()

    @Test func addition() {
        #expect(calculator.add(2, 3) == 5)
    }

    @Test func subtraction() {
        #expect(calculator.subtract(10, 4) == 6)
    }

    @Test func multiplication() {
        #expect(calculator.multiply(3, 7) == 21)
    }
}

Apple recommends starting with structs unless you specifically need reference semantics or deinit cleanup. Each test gets a fresh instance of the suite struct, so there's natural isolation between tests — no leftover state from a previous run leaking in and causing mystery failures.

Nested Suites

Suites can nest inside other suites, giving you a clean hierarchy:

@Suite("User Management")
struct UserManagementTests {

    @Suite("Registration")
    struct RegistrationTests {
        @Test func validEmailAccepted() {
            let result = UserValidator.validateEmail("[email protected]")
            #expect(result == .valid)
        }

        @Test func invalidEmailRejected() {
            let result = UserValidator.validateEmail("not-an-email")
            #expect(result == .invalid)
        }
    }

    @Suite("Authentication")
    struct AuthenticationTests {
        @Test func correctPasswordSucceeds() throws {
            let auth = Authenticator()
            let result = try auth.login(username: "admin", password: "correct")
            #expect(result.isAuthenticated)
        }

        @Test func wrongPasswordFails() throws {
            let auth = Authenticator()
            let result = try auth.login(username: "admin", password: "wrong")
            #expect(!result.isAuthenticated)
        }
    }
}

Xcode's test navigator renders this hierarchy beautifully, making it easy to run specific groups of tests. It's the kind of thing that seems minor until you have a few hundred tests and need to zero in on a specific area.

Suite Initialization for Setup

Need setup logic? Just use the suite's init(). It replaces XCTest's setUp() method:

@Suite("Database Operations")
struct DatabaseTests {
    let database: TestDatabase

    init() throws {
        database = try TestDatabase.createInMemory()
        try database.runMigrations()
    }

    @Test func insertRecord() throws {
        try database.insert(Record(name: "Test"))
        #expect(database.count == 1)
    }

    @Test func deleteRecord() throws {
        try database.insert(Record(name: "Test"))
        try database.delete(where: .name("Test"))
        #expect(database.count == 0)
    }
}

Since each test creates a fresh struct instance, init() runs before every test — exactly like setUp() in XCTest. For cleanup, you'd use a deinit (which requires switching to a class suite).

Parameterized Tests: Write Once, Test Many

This is, in my opinion, one of Swift Testing's killer features — something XCTest never offered natively. Parameterized tests let you run the same test logic over a collection of inputs, and each input becomes its own independently reportable test case.

Single Parameter

@Test(arguments: [1, 2, 3, 4, 5])
func squareIsAlwaysPositive(_ value: Int) {
    #expect(value * value > 0)
}

This generates five distinct test cases in the test navigator. Each can pass or fail independently, making it trivially easy to spot which specific input caused a failure.

Multiple Parameters (Combinatorial)

When you provide multiple argument collections, Swift Testing tests every combination:

@Test(arguments: ["USD", "EUR", "GBP"], [100.0, 0.0, -50.0])
func currencyFormattingHandlesAllCases(currency: String, amount: Double) {
    let formatter = CurrencyFormatter(currency: currency)
    let result = formatter.format(amount)
    #expect(!result.isEmpty)
    #expect(result.contains(currency))
}

That produces 9 test cases (3 currencies x 3 amounts). Each combination shows up separately in the test report. It's incredibly powerful for validation logic, parsers, formatters — basically anything that needs to handle diverse inputs reliably.

Paired Parameters with zip

Sometimes you don't want every combination — you want specific pairs. That's where zip comes in:

@Test(arguments: zip(
    ["[email protected]", "bob@test", "invalid", "[email protected]"],
    [true, false, false, true]
))
func emailValidation(email: String, shouldBeValid: Bool) {
    let result = EmailValidator.validate(email)
    #expect(result.isValid == shouldBeValid)
}

This runs exactly four test cases, each pairing an email with its expected result. No combinatorial explosion to worry about.

Using Enums as Arguments

Any CaseIterable enum works beautifully with parameterized tests:

enum Theme: String, CaseIterable {
    case light, dark, system
}

@Test(arguments: Theme.allCases)
func themeCanBeApplied(_ theme: Theme) {
    let manager = ThemeManager()
    manager.apply(theme)
    #expect(manager.currentTheme == theme)
}

Every enum case becomes a test case. Add a new case to the enum, and you automatically get a new test — no code changes needed. That's the kind of design that makes you smile.

Tags: Cross-Cutting Test Organization

Tags let you categorize tests across suite boundaries. Unlike suites (which give you a rigid hierarchy), tags are flexible labels you can mix and match however you want.

Defining Custom Tags

extension Tag {
    @Tag static var networking: Self
    @Tag static var persistence: Self
    @Tag static var ui: Self
    @Tag static var slow: Self
    @Tag static var critical: Self
}

Applying Tags

@Test(.tags(.networking, .critical))
func apiEndpointReturnsValidJSON() async throws {
    let response = try await APIClient.shared.fetchUsers()
    #expect(!response.isEmpty)
}

@Test(.tags(.persistence))
func coreDataSavePersistsCorrectly() throws {
    let context = TestCoreDataStack().viewContext
    let entity = Item(context: context)
    entity.name = "Test"
    try context.save()

    let fetched = try context.fetch(Item.fetchRequest())
    #expect(fetched.count == 1)
}

Suite-Level Tags

When you apply a tag to a suite, every test inside inherits it:

@Suite(.tags(.networking))
struct APIClientTests {
    // All tests here automatically have the .networking tag

    @Test(.tags(.critical))
    func authenticationEndpoint() async throws {
        // This test has both .networking AND .critical tags
    }

    @Test
    func profileEndpoint() async throws {
        // This test has the .networking tag from the suite
    }
}

In Xcode, you can filter and run tests by tag. This is incredibly useful in CI pipelines — run only .critical tests on every push, run the full suite nightly, or skip .slow tests during development. Once you start using tags, you'll wonder how you managed without them.

Traits: Customizing Test Behavior

Traits are modifiers that change how tests run. Tags are one type of trait, but the system goes much deeper than that.

Disabling Tests

@Test(.disabled("Server migration in progress — re-enable after Feb 28"))
func integrationTestAgainstStagingServer() async throws {
    // Won't run, but shows up in the test navigator with the reason
}

Unlike XCTest's common hack of prefixing methods with an underscore or (worse) commenting them out entirely, disabled tests in Swift Testing remain visible and documented. The reason string shows up in the test report so nobody has to dig through git blame to figure out why a test isn't running.

Conditional Execution

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func onlyRunsOnCI() async throws {
    // Skipped locally, runs in CI
}

@Test(.disabled(
    "Requires watchOS 11 simulator",
    .when(ProcessInfo.processInfo.operatingSystemVersion.majorVersion < 15)
))
func watchOSSpecificTest() {
    // Disabled with a reason on older OS versions
}

Time Limits

@Test(.timeLimit(.minutes(2)))
func longRunningOperation() async throws {
    let result = try await processor.processLargeDataset()
    #expect(result.recordCount > 0)
}

If the test exceeds the time limit, it fails automatically. This is excellent for catching performance regressions or hanging async operations — especially in CI where you don't want a stuck test holding up your entire pipeline.

Bug References

@Test(.bug("https://github.com/myorg/myrepo/issues/42", "Login fails with special characters"))
func loginWithSpecialCharacters() throws {
    let result = try Authenticator().login(username: "user@name", password: "p@$$w0rd!")
    #expect(result.isAuthenticated)
}

Bug references create clickable links in Xcode's test report, connecting your tests directly to the issues they validate. It's a small touch, but it makes a real difference when you're triaging failures.

Serial Execution

By default, Swift Testing runs all tests in parallel. If you have tests that share state or must run sequentially, use the .serialized trait on their suite:

@Suite(.serialized)
struct SharedDatabaseTests {
    @Test func step1CreateTable() throws {
        try SharedDatabase.shared.createTable("users")
    }

    @Test func step2InsertRow() throws {
        try SharedDatabase.shared.insert(into: "users", values: ["Alice"])
    }

    @Test func step3VerifyRow() throws {
        let rows = try SharedDatabase.shared.fetchAll(from: "users")
        #expect(rows.count == 1)
    }
}

Use .serialized sparingly, though. Apple recommends refactoring tests to run in parallel whenever possible — serial tests are slower and can actually mask concurrency bugs in your code.

Handling Known Issues with withKnownIssue

Sometimes you know a test is going to fail — maybe a bug is tracked but hasn't been fixed yet. Rather than disabling the test entirely (and probably forgetting about it for months), you can mark it as a known issue:

@Test func parseMalformedDateString() {
    withKnownIssue("Parser doesn't handle timezone offsets yet — issue #87") {
        let date = DateParser.parse("2025-12-01T10:00:00+05:30")
        #expect(date != nil)
    }
}

The test still runs, the failure is recorded, but it doesn't count as a test failure. Here's the brilliant part: when the underlying bug gets fixed and the test starts passing, Swift Testing flags this as an unexpected success — reminding you to remove the withKnownIssue block. No more known-issue markers silently hiding tests that have actually been fixed.

There's also an isIntermittent parameter for flaky tests:

@Test func networkDependentOperation() async throws {
    withKnownIssue("Flaky on CI due to network throttling", isIntermittent: true) {
        let result = try await fetchFromStaging()
        #expect(result.statusCode == 200)
    }
}

When isIntermittent is true, the test passes regardless of outcome — but failures are still recorded as known issues for reporting purposes.

Async Testing and Swift Concurrency

Swift Testing was designed hand-in-hand with Swift's concurrency model. Async tests just... work:

@Test func fetchUserProfile() async throws {
    let client = APIClient()
    let profile = try await client.fetchProfile(userId: "123")

    #expect(profile.name == "Alice")
    #expect(profile.email.contains("@"))
}

No completion handlers. No expectations to fulfill. No timeouts to set. Just async/await. If you've ever struggled with XCTest's XCTestExpectation and waitForExpectations(timeout:), you'll appreciate how refreshing this is.

Testing Actor-Isolated Code

If you're testing code that's isolated to a specific actor, Swift Testing handles it cleanly:

actor ShoppingCart {
    private var items: [CartItem] = []

    func add(_ item: CartItem) {
        items.append(item)
    }

    func total() -> Double {
        items.reduce(0) { $0 + $1.price }
    }

    var itemCount: Int { items.count }
}

@Test func shoppingCartAccumulatesItems() async {
    let cart = ShoppingCart()

    await cart.add(CartItem(name: "Widget", price: 9.99))
    await cart.add(CartItem(name: "Gadget", price: 24.99))

    let count = await cart.itemCount
    let total = await cart.total()

    #expect(count == 2)
    #expect(total == 34.98)
}

The Confirmation API

For testing callback-based asynchronous APIs that haven't been migrated to async/await yet, Swift Testing provides the confirmation API — think of it as the modern replacement for XCTest expectations:

@Test func notificationIsPosted() async {
    await confirmation("Notification received") { confirm in
        let observer = NotificationCenter.default.addObserver(
            forName: .userDidLogin,
            object: nil,
            queue: .main
        ) { _ in
            confirm()
        }

        LoginManager.shared.performLogin(username: "test", password: "test")

        // Cleanup
        NotificationCenter.default.removeObserver(observer)
    }
}

The confirmation function expects the closure to call confirm() exactly once by default. If it's never called or called multiple times, the test fails. Simple and clear.

Ranged Confirmations (Swift 6.2)

New in Swift 6.2, you can specify flexible expected counts for confirmations:

@Test func delegateCalledMultipleTimes() async {
    await confirmation("Delegate method called", expectedCount: 3) { confirm in
        let delegate = TestDelegate(onCallback: { confirm() })
        let processor = EventProcessor(delegate: delegate)
        processor.processEvents(["a", "b", "c"])
    }
}

// Or use a range for flexible expectations
@Test func eventsFiredWithinRange() async {
    await confirmation("Events fired", expectedCount: 2...5) { confirm in
        let tracker = EventTracker(onEvent: { confirm() })
        tracker.startMonitoring()
        // ... trigger events
    }
}

The range-based overload is perfect for testing asynchronous systems where the exact number of events might vary within an acceptable window.

Exit Tests: Testing Fatal Conditions (Swift 6.2)

One of the most requested features in Swift testing has been the ability to test code that deliberately terminates the process — things like fatalError(), preconditionFailure(), or exit() calls. Until Swift 6.2, neither XCTest nor Swift Testing could handle this without the test runner itself crashing. Which, as you can imagine, was not ideal.

Exit tests solve this by running the code under test in a separate process:

@Test func outOfBoundsAccessCausesFatalError() async {
    await #expect(processExitsWith: .failure) {
        let array = [1, 2, 3]
        _ = array[10] // This crashes — but only in the child process
    }
}

@Test func preconditionCatchesInvalidState() async {
    await #expect(processExitsWith: .failure) {
        let engine = GameEngine()
        engine.state = .uninitialized
        engine.start() // preconditionFailure("Engine must be initialized")
    }
}

You can also test for successful exit or specific exit codes:

@Test func cliToolExitsCleanly() async {
    await #expect(processExitsWith: .success) {
        CLITool.run(arguments: ["--version"])
    }
}

@Test func cliToolExitsWithSpecificCode() async {
    await #expect(processExitsWith: .exitCode(42)) {
        CLITool.run(arguments: ["--invalid-flag"])
    }
}

This is a genuine game-changer for testing defensive programming patterns. Previously, you'd either skip testing fatal paths entirely or use awkward workarounds. Now you can just... verify them directly.

Test Scoping Traits: Custom Setup and Teardown (Swift 6.1+)

Starting with Swift 6.1, you can create custom traits that execute logic before and after tests run. This is the mechanism for sharing setup and teardown logic across unrelated suites — something XCTest's class hierarchy was often (let's be honest) abused to achieve.

Creating a Custom Scoping Trait

struct MockNetworkTrait: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing body: @Sendable () async throws -> Void
    ) async throws {
        // Setup: install mock network layer
        let mockSession = MockURLSession()
        URLSessionProvider.shared.override(with: mockSession)

        // Run the test
        try await body()

        // Teardown: restore real network layer
        URLSessionProvider.shared.restoreDefault()
    }
}

extension TestTrait where Self == MockNetworkTrait {
    static var mockNetwork: Self { MockNetworkTrait() }
}

Using the Custom Trait

@Test(.mockNetwork)
func fetchUsersWithMockedNetwork() async throws {
    let users = try await UserService.fetchAll()
    #expect(!users.isEmpty)
}

// Or apply to an entire suite
@Suite(.mockNetwork)
struct NetworkDependentTests {
    @Test func fetchProfile() async throws { /* ... */ }
    @Test func updateSettings() async throws { /* ... */ }
}

This pattern is incredibly powerful. You can create traits for database transactions (automatically rolled back after each test), authentication contexts, feature flag overrides, locale settings — you name it. Each trait encapsulates its own setup and teardown, and they compose naturally. Stack multiple traits on a single test or suite and everything just works.

Accessing Trait State from Tests

For traits that need to pass data into the test, you can use Swift's task-local values:

enum TestDatabaseKey {
    @TaskLocal static var current: TestDatabase?
}

struct DatabaseTrait: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing body: @Sendable () async throws -> Void
    ) async throws {
        let db = try TestDatabase.createInMemory()
        try db.runMigrations()

        try await TestDatabaseKey.$current.withValue(db) {
            try await body()
        }

        try db.tearDown()
    }
}

extension TestTrait where Self == DatabaseTrait {
    static var database: Self { DatabaseTrait() }
}

// Usage
@Test(.database)
func insertUser() throws {
    let db = try #require(TestDatabaseKey.current)
    try db.insert(User(name: "Alice"))
    #expect(try db.fetchAllUsers().count == 1)
}

Migrating from XCTest: A Practical Guide

If you have an existing test suite in XCTest, here's the good news: both frameworks coexist perfectly within the same test target. You can migrate incrementally — no big-bang rewrite needed. I'd actually recommend this approach; trying to convert everything at once is a recipe for introducing regressions.

Coexistence Rules

There's one critical rule: never mix frameworks within a single test. Don't call XCTAssert from a Swift Testing test, and don't use #expect from an XCTestCase method. Each test should use one framework exclusively.

However, both types of tests can live in the same file and even the same test target. No issues there.

Migration Cheat Sheet

Here's a quick reference for the most common patterns:

// XCTest                              →  Swift Testing
// ─────────────────────────────────────────────────────
// class FooTests: XCTestCase          →  @Suite struct FooTests
// func testSomething()                →  @Test func something()
// XCTAssertEqual(a, b)                →  #expect(a == b)
// XCTAssertTrue(condition)            →  #expect(condition)
// XCTAssertFalse(condition)           →  #expect(!condition)
// XCTAssertNil(value)                 →  #expect(value == nil)
// XCTAssertNotNil(value)              →  #expect(value != nil)
// XCTAssertGreaterThan(a, b)          →  #expect(a > b)
// XCTAssertThrowsError(expr)          →  #expect(throws: SomeError.self) { expr }
// try XCTUnwrap(optional)             →  try #require(optional)
// setUp() / setUpWithError()          →  init() / init() throws
// tearDown()                          →  deinit (class suites only)
// XCTSkip("reason")                   →  .disabled("reason") trait
// addTeardownBlock { }                →  Scoping traits
// measure { }                         →  Not yet available
// XCUIApplication                     →  Not yet available

What You Can't Migrate Yet

Two categories of tests should stay in XCTest for now:

  • Performance tests: XCTMetric-based performance measurement doesn't have a Swift Testing equivalent yet.
  • UI tests: XCUIApplication and UI automation remain XCTest-only. Swift Testing doesn't have a UI testing story yet, though Xcode 26 introduced new recording capabilities that complement XCTest's UI testing.

A Real Migration Example

Let's walk through migrating a typical XCTest class step by step:

// BEFORE: XCTest
class UserServiceTests: XCTestCase {
    var sut: UserService!
    var mockNetwork: MockNetworkClient!

    override func setUp() {
        super.setUp()
        mockNetwork = MockNetworkClient()
        sut = UserService(network: mockNetwork)
    }

    override func tearDown() {
        sut = nil
        mockNetwork = nil
        super.tearDown()
    }

    func testFetchUserReturnsCorrectName() async throws {
        mockNetwork.stubResponse(User(name: "Alice", age: 30))
        let user = try await sut.fetchUser(id: "123")
        XCTAssertEqual(user.name, "Alice")
    }

    func testFetchUserThrowsOnNetworkError() async {
        mockNetwork.stubError(NetworkError.timeout)
        do {
            _ = try await sut.fetchUser(id: "123")
            XCTFail("Expected error")
        } catch {
            XCTAssertTrue(error is NetworkError)
        }
    }

    func testFetchUserReturnsCorrectAge() async throws {
        mockNetwork.stubResponse(User(name: "Alice", age: 30))
        let user = try await sut.fetchUser(id: "123")
        XCTAssertEqual(user.age, 30)
    }
}
// AFTER: Swift Testing
@Suite("User Service")
struct UserServiceTests {
    let sut: UserService
    let mockNetwork: MockNetworkClient

    init() {
        mockNetwork = MockNetworkClient()
        sut = UserService(network: mockNetwork)
    }

    @Test func fetchUserReturnsCorrectData() async throws {
        mockNetwork.stubResponse(User(name: "Alice", age: 30))
        let user = try await sut.fetchUser(id: "123")

        #expect(user.name == "Alice")
        #expect(user.age == 30)
    }

    @Test func fetchUserThrowsOnNetworkError() async {
        mockNetwork.stubError(NetworkError.timeout)

        #expect(throws: NetworkError.self) {
            try await sut.fetchUser(id: "123")
        }
    }
}

Notice the improvements: no forced unwrapping of sut, no tearDown needed (struct value semantics handle cleanup automatically), the two name/age assertions merged into one test since #expect continues on failure, and the error test is drastically simplified. Less code, fewer footguns, clearer intent.

Advanced Patterns and Best Practices

Descriptive Test Names

The @Test macro accepts an optional display name that shows in the test navigator:

@Test("Expired tokens trigger automatic refresh")
func expiredTokenRefresh() async throws {
    let auth = AuthManager(tokenExpiry: .distantPast)
    let response = try await auth.makeAuthenticatedRequest()
    #expect(response.usedFreshToken)
}

Use display names when the function name alone doesn't quite convey the test's intent. They're especially helpful for parameterized tests or when the behavior being tested is nuanced.

Combining Traits Effectively

@Test(
    "Bulk import processes all records",
    .tags(.persistence, .slow),
    .timeLimit(.minutes(5)),
    .bug("https://github.com/org/repo/issues/99")
)
func bulkImport() async throws {
    let importer = DataImporter()
    let result = try await importer.importCSV(testLargeDatasetURL)
    #expect(result.successCount > 0)
    #expect(result.errorCount == 0)
}

Traits compose naturally. Combine tags, time limits, bug references, and conditions to create self-documenting tests that tell you exactly what they're about at a glance.

Using #expect with Custom Messages

While #expect's automatic value capture is usually sufficient, you can add custom messages when extra context helps:

@Test func priceCalculation() {
    let cart = ShoppingCart()
    cart.add(item: widget, quantity: 3)
    cart.applyDiscount(.percentage(10))

    let total = cart.total
    #expect(
        abs(total - 26.97) < 0.01,
        "Expected total ~26.97 after 10% discount on 3 × $9.99, got \(total)"
    )
}

Testing with Swift 6.2's Approachable Concurrency

If you've explored Swift 6.2's approachable concurrency features, you'll appreciate how well Swift Testing integrates with the new concurrency defaults. With default MainActor isolation enabled in your module, your tests work seamlessly:

// With defaultIsolation(MainActor.self) in your test target
@Suite struct ViewModelTests {
    // Implicitly @MainActor — perfect for testing @MainActor view models
    let viewModel = ContentViewModel()

    @Test func loadDataUpdatesPublishedProperty() async throws {
        await viewModel.loadData()
        #expect(!viewModel.items.isEmpty)
    }
}

For tests that need to run off the main actor, use the @concurrent attribute introduced in Swift 6.2:

@Test @concurrent
func heavyComputationDoesNotBlockMainActor() async {
    let result = await DataProcessor.crunchNumbers(input: largeDataset)
    #expect(result.isValid)
}

Structuring a Real-World Test Suite

Here's a recommended structure for organizing tests in a production project:

Tests/
├── MyAppTests/
│   ├── Tags.swift                  // Custom tag definitions
│   ├── Traits/
│   │   ├── MockNetworkTrait.swift
│   │   ├── DatabaseTrait.swift
│   │   └── AuthenticatedTrait.swift
│   ├── Models/
│   │   ├── UserTests.swift
│   │   └── ProductTests.swift
│   ├── Services/
│   │   ├── AuthServiceTests.swift
│   │   ├── APIClientTests.swift
│   │   └── CacheServiceTests.swift
│   ├── ViewModels/
│   │   ├── HomeViewModelTests.swift
│   │   └── ProfileViewModelTests.swift
│   └── Helpers/
│       ├── MockNetworkClient.swift
│       └── TestFixtures.swift

A few key principles worth following:

  • Mirror your source structure — if the source has Services/AuthService.swift, tests go in Services/AuthServiceTests.swift. Makes things easy to find.
  • Centralize tags — define all custom tags in one file for discoverability.
  • Extract reusable traits — scoping traits go in their own directory, ready to compose across any suite.
  • Separate helpers — mocks, stubs, and fixtures get their own space so they don't clutter your actual test files.

Common Pitfalls and How to Avoid Them

1. Overusing .serialized

It's tempting to slap .serialized on suites when tests fail in parallel. Resist the urge. Parallel failures almost always point to shared mutable state — fix the state isolation instead. Use unique database instances, separate mock objects, or dependency injection.

2. Forgetting That Struct Suites Create Fresh Instances

Each test gets its own suite struct instance. This is usually great, but it means you can't share state between tests within a suite. If you're used to XCTest's class-based test suites where setUp runs once and state persists across tests, this requires a mental model shift.

3. Mixing Frameworks in a Single Test

Just don't:

// DON'T: Mixing frameworks
@Test func broken() {
    XCTAssertEqual(1, 1)  // This will not work correctly
    #expect(2 == 2)
}

Each test must use one framework exclusively. Pick one and stick with it.

4. Not Leveraging Parameterized Tests

If you find yourself copy-pasting test functions with slightly different inputs, that's a parameterized test waiting to happen. Before you write the third copy of a similar test, stop and refactor to @Test(arguments:). Your future self will thank you.

5. Ignoring the #require vs #expect Distinction

Use #require for preconditions where continuing after failure would be meaningless. Use #expect for everything else. Here's the pattern I've found works best:

@Test func parseAndValidate() throws {
    // Precondition: parsing must succeed
    let user = try #require(User.parse(from: jsonData))

    // Assertions: check the parsed values
    #expect(user.name == "Alice")
    #expect(user.age == 30)
    #expect(user.email.contains("@"))
}

What's Coming Next

Swift Testing is under active development, and the pace of evolution has been encouraging. The framework gained six proposals between Swift 6.0 and 6.2, covering exit tests, ranged confirmations, test scoping traits, and improved console output. Areas to watch for future enhancements include:

  • Performance testing: There's no XCTMetric equivalent yet, but it's a known gap the team is aware of.
  • UI testing integration: Currently XCTest-only, but the groundwork for a Swift Testing-native approach may be in the works.
  • Snapshot testing: Third-party libraries like swift-snapshot-testing work today, but deeper framework integration would be welcome.
  • Enhanced Xcode integration: Each Xcode release brings improved test navigator support, inline failure annotations, and tag-based filtering.

The trajectory is clear: Swift Testing is the future of testing on Apple platforms. XCTest isn't deprecated, and you definitely don't need to migrate overnight. But for new tests? Swift Testing is the right choice today — it's more expressive, more powerful, and built for the modern Swift ecosystem.

Wrapping Up

Swift Testing represents a genuine paradigm shift in how we write tests for Swift. The macro-based API eliminates boilerplate, parameterized tests cut down on duplication, traits offer flexible customization, and native async support means no more wrestling with expectations and timeouts.

My suggestion? Start small. Write your next new test with @Test and #expect. Once you feel the difference — and you will — you'll naturally want to explore parameterized tests, tags, and custom traits. Migrate your existing XCTest code at whatever pace feels comfortable; both frameworks coexist peacefully.

The days of forty assertion functions, mandatory class inheritance, and test method prefixes are behind us. Swift Testing is the framework Swift deserves, and it's ready for production today.

About the Author Editorial Team

Our team of expert writers and editors.