# Swift Testing Expert

> Use this skill to write, review, design or migrate Swift tests, and for TDD workflow (red-green-refactor, triangulation, outside-in vs inside-out, legacy code, bug fixes). Covers Swift Testing syntax (@Test, @Suite, #expect, #require, confirmation, parameterized tests, traits), migrating an XCTest suite incrementally (API translations, interop modes, replacing addTeardownBlock and XCTestExpectation), test doubles (dummy, stub, spy, fake, mock), test design (naming, makeSUT, DSLs, snapshots, localization), making ASYNC tests deterministic without sleeps, and choosing a testing level (unit, integration, snapshot, acceptance, UI). Trigger even when "test" isn't said -- "my tests are flaky", "how do I fake the network", "this async test hangs", "should I mock this", "should I write the test first". Do NOT use for app architecture (ios-architecture-expert), BDD specs or acceptance criteria (requirements-engineering), general Swift language features (swift-language-expert), or SwiftUI view code (swiftui-expert).

- Skill: `swiftyjourney/swift-testing-expert` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add swiftyjourney/swift-testing-expert`
- Raw SKILL.md: https://api.skillmd.com/api/skills/swiftyjourney/swift-testing-expert/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: SwiftyJourney (https://skillmd.com/u/swiftyjourney)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/swiftyjourney/swift-testing-expert

---


# Swift Testing Expert

Swift Testing as the default, XCTest where it is still required, and the test-design judgement that outlives both.

## Agent Behavior Contract

When this skill is active, follow these rules **strictly**:

1. **Default to Swift Testing for new tests.** Match the project's existing framework when adding to a suite that already exists; both can live in the same test target.
2. **Name the test after the behavior, not the type** — `CacheFeedUseCaseTests`, not `LocalFeedLoaderTests`. The method name states the condition and the expected outcome.
3. **Every suite gets a `makeSUT()` factory** — it centralizes construction, tracks leaks, and forwards source location. Never share a SUT across tests, and never construct it inline in a test body. One nuance: because Swift Testing creates a **fresh suite instance per test**, stored `let` properties initialized in `init()` are also per-test state — acceptable for trivial construction, but the moment a test needs different construction parameters, leak tracking, or call-site failure reporting, that's `makeSUT()`.
4. **Hand-roll test doubles.** No mocking frameworks. A spy records messages in an enum array; a stub returns canned values. Keep them separate — see `test-doubles-and-strategy.md`.
5. **Assert observable behavior, not interactions.** Prefer "given this input, this output" over "this method was called with these arguments in this order" — except where call order *is* the contract.
6. **Never sleep to wait.** Poll for an observable state change with a timeout, or `await` something real. A fixed delay is either too slow or too short.
7. **Prove a test can fail.** Before trusting a green test — and always when fixing a bug — see it red first.
8. **Do not add production code to satisfy a test.** No `Equatable` conformance, no widened access, no test-only hooks. If a behavior is hard to observe, that's a design signal.
9. **Forward source location through helpers** so failures report at the call site: `sourceLocation: SourceLocation = #_sourceLocation` (Swift Testing) or `file:`/`line:` (XCTest).
10. **Tests are code.** Refactor, rename and deduplicate them to the same standard as production.

---

## Testing Diagnostic Table

| Symptom | First check | Smallest fix | Deep dive |
|---|---|---|---|
| Test passes alone, fails in the suite (or vice versa) | Shared state or ordering | Randomize order; clean state in setup **and** teardown | `test-design.md` |
| Async test hangs forever | `RunLoop` used in an async context | `await Task.yield()` and a timeout | `async-testing.md` |
| Async test fails intermittently near assertions | An implicit `Task` you can't await | Wait for an observable state change | `async-testing.md` |
| Test leaks / spy outlives the test | In-flight async work never cancelled | Cancel pending requests in teardown | `async-testing.md` |
| Failure message says nothing useful | Assertion inside a helper without source location | Forward `#_sourceLocation` | `test-design.md` |
| You had to debug to find why a test failed | The test asserts too much at once | Split by concern; one behavior per test | `test-design.md` |
| Production type conforms to `Equatable` only for tests | Test details leaking into production | Pattern-match in an `expect` helper | `test-doubles-and-strategy.md` |
| Mocking `URLSession` / a type you don't own | Wrong seam | `URLProtocol` stubbing, or a protocol you own | `test-doubles-and-strategy.md` |
| Suite is slow and getting slower | Wrong level for what's being tested | Move edge cases down to unit tests | `test-doubles-and-strategy.md` |
| `XCTAssert` inside a Swift Testing test silently passes | Framework interop | Understand the interop modes | `xctest-migration.md` |
| `addTeardownBlock` has no equivalent | It genuinely doesn't | A test-scoping trait (ST-0007) | `xctest-migration.md` |
| Snapshot test is green but was never recorded | `record` doesn't fail | Make `record` call `Issue.record` | `test-design.md` |
| UIKit test crashes off the main thread after migration | Tests run nonisolated by default | `@MainActor` on the suite | `isolation-and-sendability.md` |
| Spy or suite won't compile under Swift 6 | Sendability at a task boundary | `@MainActor` spy, or lock + `@unchecked Sendable` | `isolation-and-sendability.md` |

---

## Gotchas

- **Swift Testing runs tests in parallel by default.** Anything sharing a store URL, an on-disk artifact, or a registered `URLProtocol` stub needs `.serialized` on the suite. XCTest ran serially; this is the single most common migration surprise.
- **Tests are not main-actor isolated by default.** XCTest ran synchronous test methods on the main thread; Swift Testing runs every test on an arbitrary task. UIKit/SwiftUI suites need `@MainActor` — and a target compiled with default main-actor isolation (SE-0466 / "Approachable Concurrency") silently flips this. See `isolation-and-sendability.md`.
- **`@Test(arguments:)` values must be Sendable; the suite type itself need not be.** A non-Sendable argument type errors at the attribute — pass a Sendable descriptor and construct inside the test.
- **`#expect` continues; `#require` stops.** Use `try #require` when the rest of the test is meaningless without the value.
- **`XCTestExpectation` and `XCTWaiter` are not bridged into Swift Testing** and cannot be used safely in a Swift concurrency context. Use `confirmation` or a continuation.
- **`#expect(throws:)` returns the thrown error** (ST-0006), so you can inspect it instead of asserting twice.
- **A `struct` suite gets a fresh instance per test**; `deinit` is only available on a `final class` or `actor` suite.
- **`Task` does not cancel on `deinit`** the way `AnyCancellable` did — async work started by a test can outlive it and produce false memory-leak failures.
- **Swift Testing shows sub-expression values on failure** — `(feed.items.isEmpty → true) == false` — so you rarely need a custom assertion message just to see what the value was.
- **`.serialized` on a suite makes its tests run serially, not exclusively** — other suites still run in parallel alongside it.

---

## Choosing a level

Measured per-test averages from the source codebase — the reason the pyramid has the shape it does:

| Level | Per test | What it covers | How many |
|---|---|---|---|
| **Unit / isolated** | ~0.008s | Every edge case, every error course | The overwhelming majority |
| **Integration** (e.g. cache) | ~0.051s | Real components collaborating — **happy path only** | Some |
| **Acceptance** (in-process) | fast — runs in-process | The composed app: real composition root, stubbed infrastructure | One per BDD scenario |
| **Snapshot** | ~0.275s | Rendering, light/dark, large content sizes | A few per visual state |
| **API end-to-end** | ~2.06s | The client/server contract against a real backend | A handful, separate target |
| **UI (XCUITest)** | ~9.2s | — | Ideally none |

Integration test count grows **combinatorially** with the number of participating components, while unit tests grow linearly. That is the whole argument: test edge cases where they are cheap, and prove collaboration once.

> Full reasoning, and why the source codebase deleted its UI-test target entirely: `test-doubles-and-strategy.md`.

---

## Guardrails

- Do not use a mocking framework — hand-roll the double you need
- Do not assert on interactions when you can assert on outcomes
- Do not add `Equatable`, widen access, or add hooks to production for a test's benefit
- Do not use `Task.sleep` or a fixed delay to wait for anything
- Do not leave a waiting helper without a timeout — a stuck job costs CI money
- Do not test logic with snapshot tests
- Do not keep a test for an invariant the compiler now enforces
- Do not chase a coverage number — coverage shows which lines *ran*, never that behavior is correct
- Do not migrate a whole suite at once; `#expect` works inside `XCTestCase` (see `xctest-migration.md`)

---

## Verification Checklist

1. Every suite has a `makeSUT()`; no test constructs the SUT inline
2. Test names state a condition and an expected outcome
3. Reference-type SUTs and collaborators are tracked for leaks
4. Suites sharing external state are `.serialized`
5. No `Task.sleep`, no fixed delays, and every waiting helper has a timeout
6. Assertion helpers forward `#_sourceLocation` (or `file:`/`line:`)
7. Async tests cancel their pending work in teardown
8. Deliberately break the production code and confirm the relevant test goes red

---

## Reference Router

Open the smallest reference that matches the question:

- **Strategy**
  - [test-doubles-and-strategy.md](references/test-doubles-and-strategy.md) — dummy/stub/spy/fake/mock, stubbing vs spying, classicist vs mockist, testing network requests, choosing a level
- **Craft**
  - [test-design.md](references/test-design.md) — naming, `makeSUT`, message-enum spies, DSLs, one assertion per test, snapshots, localization, isolation
- **Async**
  - [async-testing.md](references/async-testing.md) — deterministic async tests, async spies, waiting without sleeping, implicit tasks, cancellation and test leaks
- **Concurrency & Swift 6**
  - [isolation-and-sendability.md](references/isolation-and-sendability.md) — `@MainActor` in tests, default isolation, Sendable requirements for suites/spies/arguments, `@Observable` view models
- **Syntax**
  - [swift-testing-syntax.md](references/swift-testing-syntax.md) — `@Test`, `@Suite`, `#expect`, `#require`, `confirmation`, parameterized tests, traits, tags
- **Migration**
  - [xctest-migration.md](references/xctest-migration.md) — the API translation table, interop modes, `trackForMemoryLeaks` as a scoping trait, what stays XCTest
- **Process**
  - [tdd-process.md](references/tdd-process.md) — red/green/refactor, commit discipline, legacy-code extraction, measuring test times

