---
name: swift-testing
description: "Writing, migrating and reviewing Swift Testing tests: @Test, @Suite, #expect, #require, confirmation, traits, withKnownIssue, Attachment.record, exit tests with processExitsWith and capture lists, Test.cancel, Issue.record warnings and manual failures, XCTest conversion (XCTUnwrap, XCTFail), mocking, async patterns, test organisation. Use when writing or migrating Swift tests, checking which Swift Testing APIs a toolchain supports, or deciding where XCTest and XCUITest still belong for UI, performance and snapshot tests."
metadata:
  source: multi-agent-pipeline
---

# Swift Testing

Swift Testing needs Xcode 16 or later and Swift 6 or later. Use it for every
new unit test. XCTest keeps UI tests (XCUITest), performance measurements and
most snapshot workflows.

More material: [patterns and migration](references/testing-patterns.md) and
[newer APIs and version gates](references/testing-advanced.md).

## A First Test

```swift
import Testing
@testable import Ledger

@Test("A new account starts with a zero balance")
func newAccountIsEmpty() {
    let account = Account(owner: "Ada")
    #expect(account.balance == 0)
}
```

## Traits

```swift
@Test("Rejects malformed IBANs", .tags(.validation, .banking))
func rejectsMalformedIBAN() { }

@Test(.disabled("Waiting for the sandbox to come back"))
func sandboxTransfer() { }

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] == "true"))
func nightlyReconciliation() { }

@Test(.bug("https://tracker.example.com/issue/481"))
func roundingRegression() { }

@Test("Imports a year of statements", .tags(.slow), .timeLimit(.minutes(2)))
func importsAYear() async throws { }
```

- The first string argument is the display name.
- `.tags(...)`, `.disabled("reason")`, `.enabled(if:)`, `.bug(...)` and
  `.timeLimit(...)` combine freely in one `@Test`.
- `.timeLimit` takes whole minutes only (`.minutes(_:)`). There is no
  `.seconds` form.

## `#expect` and `#require`

```swift
@Test func transferValidation() async throws {
    let ledger = Ledger()

    #expect(ledger.accounts.isEmpty, "A fresh ledger has no accounts")

    #expect(throws: LedgerError.self) {
        try ledger.transfer(amount: -5, from: "A", to: "B")
    }

    let error = #expect(throws: LedgerError.self) {
        try ledger.transfer(amount: 10, from: "A", to: "A")
    }
    #expect(error == .sameAccount)

    let first = try #require(await ledger.loadAccounts().first)
    #expect(first.currency == "EUR")
}
```

- `#expect` records a failure and lets the test continue. An optional message
  follows the condition.
- `#expect(throws: SomeError.self) { ... }` checks the error type. Since Swift
  6.1 it returns the thrown error, so you can inspect its value afterwards. The
  older form with a trailing `throws: { error in ... }` predicate closure is
  deprecated.
- `try #require(...)` records a failure and stops the test, like `XCTUnwrap`.
  It unwraps optionals and accepts `await` expressions.
- Pick `#require` when the rest of the test needs that value to exist; pick
  `#expect` for checks that stand on their own.

## How Tests Run

Unless told otherwise, Swift Testing runs tests concurrently. Never count on an
order, on one suite instance shared between tests, or on having anything to
yourself.

```swift
@Suite(.serialized)
struct KeychainStoreTests {
    @Test func savesToken() throws { }
    @Test func deletesToken() throws { }
}
```

`.serialized` makes the tests in that suite run one at a time. It does not
affect tests outside the suite.

- Every test builds its own state.
- A shared mutable global is a bug unless it is protected or serialized.
- `.serialized` is for exclusive access, not for ordering.
- Steps that depend on each other belong in one test or a shared helper.

## Migrating from XCTest

- Swift Testing tests do not subclass `XCTestCase`. Put `@Test` on a free
  function, or on a method of a `struct`, `class` or `actor` suite. Use a
  `static` or `class` method when no instance fixture is needed.
- Do not rewrite every assertion as `#expect`. Keep a mapping so required
  unwraps and explicit failures keep their meaning, even when asked to "just
  use #expect everywhere".
- XCTest and Swift Testing can live side by side while you migrate.
- Leave UI automation, performance measurements and common snapshot flows on
  XCTest, XCUITest or the snapshot tool. Separate files or targets help when
  they make it clear which runner owns what.

| XCTest | Swift Testing |
|--------|---------------|
| `XCTAssert...` | `#expect(...)` |
| `XCTUnwrap`, or a value later checks rely on | `try #require(...)` |
| `XCTFail("...")`, any unconditional failure | `Issue.record("...")` |

```swift
@Test func parsesHeader() throws {
    guard let header = Header(raw: "v2;utf8") else {
        Issue.record("Header did not parse")
        return
    }
    #expect(header.version == 2)
}
```

Put `@available` on the individual `@Test` function, never on a suite type or
the type that contains it.

## Known Issues

```swift
@Test func legacyExport() {
    withKnownIssue("Exporter drops the BOM; fix scheduled") {
        #expect(Exporter().csv().hasPrefix("\u{FEFF}"))
    }
}

@Test func flakyMirror() async {
    await withKnownIssue(isIntermittent: true) {
        try await ReplicaSync.run()
    }
}

@Test func arm64OnlyBug() {
    withKnownIssue {
        #expect(Checksum.fast([1, 2, 3]) == Checksum.slow([1, 2, 3]))
    } when: {
        Platform.isARM64
    }
}
```

A known issue does not fail the test. If the body records no issue at all,
Swift Testing reports a separate issue telling you the problem may have been
fixed, so the marker does not linger.

## Attachments

```swift
@Test func exportsReport() throws {
    let data = try ReportBuilder().json()
    Attachment.record(data, named: "report.json")
    #expect(!data.isEmpty)
}
```

Images (Swift 6.3, Xcode 26.4 or later) need the UI framework imported next to
`Testing`:

```swift
import Testing
import UIKit

@MainActor
@Test func rendersGauge() {
    let gauge = GaugeView(frame: CGRect(x: 0, y: 0, width: 160, height: 160))
    let image = UIGraphicsImageRenderer(bounds: gauge.bounds).image { _ in
        gauge.drawHierarchy(in: gauge.bounds, afterScreenUpdates: true)
    }
    Attachment.record(image, named: "gauge", as: .png)
}
```

## Exit Tests

Exit tests check code that ends the process: `exit()`, `fatalError()`,
`preconditionFailure()`. They need Swift 6.2 (Xcode 26.0) and run on macOS,
Linux, FreeBSD, OpenBSD and Windows. They are not available on iOS, tvOS or
watchOS. When fixing exit-test code, state both limits: the toolchain floor and
the platforms it runs on.

```swift
@Test func rejectsCorruptConfig() async {
    await #expect(processExitsWith: .failure) {
        _ = Config.loadOrDie(path: "/dev/null")
    }
}
```

## Version Gates

Check the toolchain before recommending newer APIs, and name the gate for each
API you correct.

| API | Needs |
|-----|-------|
| Exit tests (`processExitsWith:`) | Swift 6.2, Xcode 26.0; not iOS, tvOS, watchOS |
| Capture lists in exit tests | Compiles with the Swift 6.3.1 compiler (Xcode 26.4); earlier compilers not checked. Captured values must be `Sendable` and `Codable` |
| `Test.cancel(_:)`, `Issue.record(_:severity:)`, image attachments | Swift 6.3, Xcode 26.4 |

```swift
@Test func exitsWithConfiguredCode() async {
    let status: Int32 = 42
    await #expect(processExitsWith: .exitCode(status)) { [status = status as Int32] in
        exit(status)
    }
}
```

Give each captured value an explicit type (`[name = name as Type]`). With the
Swift 6.3.1 compiler a bare `[status]` fails with "Type of captured value
'status' is ambiguous", a captured value that is not `Codable` fails with "must
conform to 'Sendable' and 'Codable'", and a closure that reads `status` with no
capture list fails with "a C function pointer cannot be formed from a closure
that captures context".

A test that calls `Test.cancel(_:)` must be `throws` or `async throws`.

## Correcting Advanced API Code

| Found | Replace with |
|-------|--------------|
| `#expect(exitsWith:)` | `await #expect(processExitsWith: .failure) { ... }`. In an iOS app target, test the logic behind a smaller API that does not exit, or move the exit test to a supported host or tool target. |
| Exit-test closure reading outer values with no capture list | An explicit, typed capture list (`[code = code as Int32]`); verified with Swift 6.3.1; values `Sendable` and `Codable` |
| `Test.cancel()` in a test that is not throwing | Mark the test `async throws` and call `try Test.cancel("reason")` |
| A warning that should not fail the run | `Issue.record("message", severity: .warning)`: reported, test still passes; Swift 6.3, Xcode 26.4 |
| `Attachment(image, named:).record()` | `Attachment.record(image, named: "...", as: .png)`; accepts `UIImage`, `CGImage`, `CIImage`, `NSImage`; Swift 6.3, Xcode 26.4 |

## Common Mistakes

- Testing how the code works instead of what it does.
- No tests for the error paths of throwing functions.
- Waiting with sleeps in async tests. Use `confirmation` with an expected
  count, an injected clock, or `withKnownIssue` for known flakiness.
- Shared mutable state between tests. Set up in the suite's `init()`.
- UI tests without accessibility identifiers; XCUITest queries rely on them.
- Never testing that a `Task` stops when cancelled.
- Unclear migration boundaries. Both frameworks may share a file, but separate
  files are usually clearer for imports, ownership and runner expectations.
- Non-`Sendable` helpers shared across concurrent tests. Mark test code that
  needs the main actor `@MainActor`.
- Relying on declaration order.
- Using `.serialized` to chain the steps of a workflow. Put dependent steps in
  one test.

## Review Checklist

- [ ] New tests use `@Test` and `#expect`, not XCTest assertions
- [ ] Names describe behaviour (`fetchUserReturnsNilOnNetworkError`, not `testFetchUser`)
- [ ] Error paths have their own tests
- [ ] Async events verified with `confirmation()` rather than `Task.sleep`
- [ ] Repetitive variations are parameterized
- [ ] Tags such as `.critical` or `.slow` exist for filtering
- [ ] Mocks conform to protocols; no subclassing of concrete types
- [ ] No shared mutable state, no dependence on order or on a shared suite instance
- [ ] `.serialized` used only for exclusive resources
- [ ] Cancellable async work has a cancellation test
