# Swift Testing Patterns

Companion to the `swift-testing` skill.

## Contents

- [Suites](#suites)
- [Parameterized Tests](#parameterized-tests)
- [Execution Model](#execution-model)
- [Confirmations](#confirmations)
- [Known Issues Limited to Specific Failures](#known-issues-limited-to-specific-failures)
- [Tags](#tags)
- [Scoped Setup and Teardown with `TestScoping`](#scoped-setup-and-teardown-with-testscoping)
- [From XCTest, Step by Step](#from-xctest-step-by-step)
- [Test Doubles](#test-doubles)
- [Designing for Tests](#designing-for-tests)
- [Async and Time](#async-and-time)
- [UI Tests Stay on XCTest](#ui-tests-stay-on-xctest)
- [Performance Tests Stay on XCTest](#performance-tests-stay-on-xctest)
- [Snapshot Tests](#snapshot-tests)
- [Attachments from Files](#attachments-from-files)
- [Exit Tests](#exit-tests)
- [Organising Files](#organising-files)
- [Readable Arguments](#readable-arguments)
- [Tests That Need a Newer OS](#tests-that-need-a-newer-os)
- [More Mistakes to Avoid](#more-mistakes-to-avoid)

## Suites

```swift
import Testing
@testable import Ledger

@Suite("Sign-in")
struct SignInTests {
    let gateway: FakeAuthGateway
    let session: SessionController

    init() {
        gateway = FakeAuthGateway()
        session = SessionController(gateway: gateway)
    }

    @Test func acceptsKnownUser() async throws {
        let user = try await session.signIn(email: "ada@example.com", password: "pw")
        #expect(user.email == "ada@example.com")
    }

    @Test func rejectsWrongPassword() async {
        await #expect(throws: AuthError.wrongPassword) {
            try await session.signIn(email: "ada@example.com", password: "nope")
        }
    }
}
```

`init()` takes the place of `setUp()` and runs before every test, because each
test gets a new instance of the suite. Passing an error value, as in
`#expect(throws: AuthError.wrongPassword)`, checks for that exact error.

Suites nest for grouping:

```swift
@Suite("Currency")
struct CurrencyTests {
    @Suite("Parsing") struct Parsing {
        @Test func parsesEuro() { #expect(Money(parsing: "EUR 5")?.amount == 5) }
    }
    @Suite("Rounding") struct Rounding {
        @Test func roundsHalfEven() { #expect(Money(2.345, "EUR").rounded.amount == 2.34) }
    }
}
```

## Parameterized Tests

```swift
@Test("Postcode validation", arguments: [
    ("10115", true),
    ("ABCDE", false),
    ("", false),
])
func validatesPostcode(_ input: String, _ isValid: Bool) {
    #expect(Postcode.isValid(input) == isValid)
}

@Test(arguments: Weekday.allCases)
func everyWeekdayHasAShortName(_ day: Weekday) {
    #expect(!day.shortName.isEmpty)
}

@Test(arguments: [Plan.basic, .pro], [Region.eu, .us])
func priceExistsForEveryPlanAndRegion(_ plan: Plan, _ region: Region) {
    #expect(PriceTable.price(for: plan, in: region) != nil)
}

@Test(arguments: zip(["1", "22", "333"], [1, 2, 3]))
func digitCount(_ text: String, _ count: Int) {
    #expect(text.count == count)
}
```

- Two argument collections test every combination (a cartesian product).
- `zip` pairs them one to one instead.
- Every combination is its own test case with its own result.

## Execution Model

Swift Testing schedules tests as concurrent tasks, so many of them execute at
once.

```swift
@Suite(.serialized, .tags(.database))
struct MigrationTests {
    @Test func upgradesV1() throws { }
    @Test func upgradesV2() throws { }
}
```

Reach for `.serialized` when tests share something outside the process that
cannot be isolated: the keychain, a database, a singleton service, a fixed
location on disk. Even then every test receives a new suite value, the order
of declarations promises nothing, and fixtures that isolate state remain the
better default.

## Confirmations

```swift
@Test func postsSignedOutNotification() async {
    let center = NotificationCenter()
    let session = SessionController(notificationCenter: center)

    await confirmation("signed-out posted") { signedOut in
        let token = center.addObserver(forName: .sessionEnded, object: nil, queue: nil) { _ in
            signedOut()
        }
        await session.signOut()
        center.removeObserver(token)
    }
}

@Test func reportsEveryChunk() async {
    let uploader = ChunkUploader(chunks: 3)
    await confirmation("chunk sent", expectedCount: 3) { chunkSent in
        uploader.onChunk = { _ in chunkSent() }
        await uploader.run()
    }
}
```

Without `expectedCount`, the confirmation must fire exactly once. With it, it
must fire exactly that many times.

## Known Issues Limited to Specific Failures

```swift
@Test func vendorFeedParses() throws {
    try withKnownIssue {
        let feed = try #require(VendorFeed(data: Fixtures.brokenFeed))
        #expect(feed.items.count == 12)
    } matching: { issue in
        if case .expectationFailed(let expectation) = issue.kind {
            return expectation.isRequired
        }
        return false
    }
}
```

The `matching:` closure decides which issues count as known. Here only a
failed `#require` is excused; any other failure still fails the test. The call
needs `try` because the body uses `#require`.

## Tags

```swift
extension Tag {
    @Tag static var validation: Self
    @Tag static var banking: Self
    @Tag static var slow: Self
}
```

Filter by tag in an Xcode test plan or from the command line. The command-line
syntax has changed between toolchains, so check it for the Swift version in
use.

## Scoped Setup and Teardown with `TestScoping`

`TestScoping` (Swift 6.1) wraps a test or suite in custom setup and teardown.

```swift
struct TemporaryDatabaseTrait: TestTrait, SuiteTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> Void
    ) async throws {
        let database = try await TestDatabase.create()
        do {
            try await function()
        } catch {
            try? await database.destroy()
            throw error
        }
        try await database.destroy()
    }
}

extension Trait where Self == TemporaryDatabaseTrait {
    static var temporaryDatabase: Self { Self() }
}

@Test(.temporaryDatabase, .tags(.database))
func insertsRows() async throws { }
```

Create the resource, run the test body, then clean up. If the body throws,
clean up with `try?` and rethrow the original error.

## From XCTest, Step by Step

Possible shapes after migration:

```swift
@Test func trimsWhitespace() {
    #expect(" a ".trimmed == "a")
}

@Suite struct TaxTests {
    let calculator = TaxCalculator(rate: 0.2)
    @Test func addsTax() { #expect(calculator.gross(100) == 120) }
}

@Suite actor ThumbnailCacheTests {
    var cache = ThumbnailCache()
    @Test func storesImage() async { await cache.store(Data(), for: "a") }
}

struct SlugTests {
    @Test static func lowercases() { #expect(Slug("Hello").value == "hello") }
}
```

| XCTest | Swift Testing |
|--------|---------------|
| `XCTAssert(isOpen)` or `XCTAssertTrue(isOpen)` | `#expect(isOpen)` |
| `XCTAssertFalse(isOpen)` | `#expect(!isOpen)` |
| `XCTAssertEqual(total, 120)` | `#expect(total == 120)` |
| `XCTAssertThrowsError(try parse(raw))` | `#expect(throws: (any Error).self) { try parse(raw) }` |
| `XCTAssertNoThrow(try parse(raw))` | `#expect(throws: Never.self) { try parse(raw) }` |
| `let user = try XCTUnwrap(maybeUser)` | `let user = try #require(maybeUser)` |
| `XCTFail("unreachable")` | `Issue.record("unreachable")` |

- Turn `setUp` into the suite's `init()`, one fresh fixture per test.
- Never move fixtures into singletons or globals to share them.
- Mutable test doubles belong in an actor or in a fixture created per test.
- Keep `.serialized` for the case where an outside resource truly cannot be
  given to each test separately.

## Test Doubles

```swift
protocol ProfileStore: Sendable {
    func profile(id: UUID) async throws -> Profile
    func save(_ profile: Profile) async throws
}

actor FakeProfileStore: ProfileStore {
    var stored: Profile?
    var failure: Error?
    private(set) var saved: [Profile] = []

    init(stored: Profile? = nil, failure: Error? = nil) {
        self.stored = stored
        self.failure = failure
    }

    func profile(id: UUID) async throws -> Profile {
        if let failure { throw failure }
        guard let stored else { throw StoreError.notFound }
        return stored
    }

    func save(_ profile: Profile) async throws {
        saved.append(profile)
    }
}
```

Doubles conform to a protocol; they never subclass a concrete type. Keep their
mutable state, call counts and captured arguments behind an actor or other
`Sendable` isolation.

## Designing for Tests

```swift
@MainActor
@Observable
final class ProfileScreenModel {
    private let store: any ProfileStore
    private(set) var profile: Profile?
    private(set) var failure: Error?

    init(store: any ProfileStore) { self.store = store }

    func load(id: UUID) async {
        do { profile = try await store.profile(id: id) }
        catch { failure = error }
    }
}

@Test @MainActor func loadShowsProfile() async {
    let expected = Profile(id: UUID(), name: "Ada")
    let model = ProfileScreenModel(store: FakeProfileStore(stored: expected))
    await model.load(id: expected.id)
    #expect(model.profile == expected)
}

@Test @MainActor func loadFailureLeavesNoProfile() async {
    let model = ProfileScreenModel(store: FakeProfileStore(failure: StoreError.offline))
    await model.load(id: UUID())
    #expect(model.profile == nil)
    #expect(model.failure != nil)
}
```

Dependencies come in through the initialiser, so tests pass a double.

## Async and Time

Mark a test `@MainActor` when it drives a main-actor model, as above.

For time-based behaviour, inject a `Clock` and drive it by hand in the test.
The standard library has no manual test clock, so write a small one or use a
clock library.

```swift
@Test func debouncerWaitsForQuietPeriod() async {
    let clock = ManualClock()
    let search = DebouncedSearch(delay: .seconds(1), clock: clock)
    search.queryChanged("sw")

    await clock.advance(by: .milliseconds(600))
    #expect(await search.requestCount == 0)

    await clock.advance(by: .milliseconds(400))
    #expect(await search.requestCount == 1)
}

@Test func offlineFetchThrows() async {
    await #expect(throws: URLError.self) {
        try await FeedClient(session: .offline).latest()
    }
}
```

## UI Tests Stay on XCTest

Swift Testing has no UI testing support; use XCTest with XCUITest.

```swift
import XCTest

@MainActor
final class CheckoutUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() async throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testGuestCanReachPayment() {
        let payment = SignInPage(app: app).continueAsGuest().addFirstItem().checkout()
        XCTAssertTrue(payment.payButton.waitForExistence(timeout: 5))
    }
}

@MainActor
struct SignInPage {
    let app: XCUIApplication
    var emailField: XCUIElement { app.textFields["signIn.email"] }
    var passwordField: XCUIElement { app.secureTextFields["signIn.password"] }
    var guestButton: XCUIElement { app.buttons["signIn.guest"] }

    @discardableResult
    func continueAsGuest() -> CatalogPage {
        guestButton.tap()
        return CatalogPage(app: app)
    }
}
```

Page objects wrap element lookups by accessibility identifier and expose
actions that return the next page. `XCUIApplication` and `XCUIElement` are
main-actor types, so under Swift 6 the test class and every page object are
`@MainActor`, and setup goes in the async `setUp()`: the synchronous
`setUpWithError()` override stays nonisolated and cannot touch them.

## Performance Tests Stay on XCTest

```swift
final class ParserPerformanceTests: XCTestCase {
    func testParseLargeFeed() {
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
            _ = FeedParser().parse(Fixtures.largeFeed)
        }
    }
}
```

## Snapshot Tests

The open-source swift-snapshot-testing package, added through SwiftPM to the
test target, is the common choice. It started as an XCTest tool; releases from
1.17 on also work inside Swift Testing.

```swift
import SnapshotTesting
import SwiftUI
import XCTest

final class ReceiptViewSnapshotTests: XCTestCase {
    func testReceipt() {
        let receipt = ReceiptView(receipt: .sample)
        let phone = SwiftUISnapshotLayout.device(config: .iPhone13)

        assertSnapshot(of: receipt, as: .image(layout: phone))
        assertSnapshot(of: receipt.environment(\.colorScheme, .dark),
                       as: .image(layout: phone), named: "night")
        assertSnapshot(of: receipt.dynamicTypeSize(.accessibility3),
                       as: .image(layout: phone), named: "largest-text")
    }
}
```

Always include a Dark Mode snapshot and one at a large Dynamic Type size.

## Attachments from Files

```swift
@Test func archivesLog() async throws {
    let url = try LogExporter().export()
    let logFile = try await Attachment(contentsOf: url, named: "session.log")
    Attachment.record(logFile)
}
```

`Data`, `[UInt8]` and `String` can be attached directly. With Foundation
imported, an `Encodable` type (or an `NSSecureCoding` one) gets a default
implementation once it declares `Attachable` conformance.

## Exit Tests

An exit test runs its closure in a child process and passes when that process
ends with the expected status. See the skill for the toolchain and platform
limits.

## Organising Files

```text
Tests/
  AppTests/            Swift Testing
    Models/
    ViewModels/
    Services/
  AppUITests/          XCTest UI tests
    Pages/
    Flows/
  Fixtures/
  Mocks/
```

- Files are named after the type under test: `InvoiceStoreTests.swift`.
- Test functions describe behaviour.
- Doubles are named after the protocol: `MockProfileStore` for
  `ProfileStore` (or `Fake...` when it has working behaviour).

Always test business rules, validation, view-model state transitions, error
paths, edge cases (empty input, nil, boundary values), both outcomes of async
calls, and cancellation of `Task`s.

Skip SwiftUI `body` layout (snapshots cover it), trivial forwarding code, the
behaviour of Apple frameworks, and private methods (test them through the
public API).

## Readable Arguments

Parameterized results print each argument with `String(describing:)`. For
enums, identifiers and models, conform to `CustomTestStringConvertible`:

```swift
extension Plan: CustomTestStringConvertible {
    var testDescription: String { "plan:\(rawValue)" }
}
```

## Tests That Need a Newer OS

```swift
@Test @available(macOS 15, iOS 18, *)
func usesNewMeshGradientAPI() { }
```

Swift Testing skips the test on older systems, which replaces XCTest's
`#available` check with an early return. Suites must always be available:
never put `@available` on a suite or on the type that contains it.

## More Mistakes to Avoid

- Neither declaration order nor nesting of suites turns tests into a workflow;
  they still run in parallel.
- A serialized suite prevents overlap, but tests in it still do not hand state
  to each other.
