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:
- 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.
- Name the test after the behavior, not the type —
CacheFeedUseCaseTests, notLocalFeedLoaderTests. The method name states the condition and the expected outcome. - 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, storedletproperties initialized ininit()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'smakeSUT(). - 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. - 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.
- Never sleep to wait. Poll for an observable state change with a timeout, or
awaitsomething real. A fixed delay is either too slow or too short. - Prove a test can fail. Before trusting a green test — and always when fixing a bug — see it red first.
- Do not add production code to satisfy a test. No
Equatableconformance, no widened access, no test-only hooks. If a behavior is hard to observe, that's a design signal. - Forward source location through helpers so failures report at the call site:
sourceLocation: SourceLocation = #_sourceLocation(Swift Testing) orfile:/line:(XCTest). - 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
URLProtocolstub needs.serializedon 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. Seeisolation-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.#expectcontinues;#requirestops. Usetry #requirewhen the rest of the test is meaningless without the value.XCTestExpectationandXCTWaiterare not bridged into Swift Testing and cannot be used safely in a Swift concurrency context. Useconfirmationor a continuation.#expect(throws:)returns the thrown error (ST-0006), so you can inspect it instead of asserting twice.- A
structsuite gets a fresh instance per test;deinitis only available on afinal classoractorsuite. Taskdoes not cancel ondeinitthe wayAnyCancellabledid — 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. .serializedon 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.sleepor 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;
#expectworks insideXCTestCase(seexctest-migration.md)
Verification Checklist
- Every suite has a
makeSUT(); no test constructs the SUT inline - Test names state a condition and an expected outcome
- Reference-type SUTs and collaborators are tracked for leaks
- Suites sharing external state are
.serialized - No
Task.sleep, no fixed delays, and every waiting helper has a timeout - Assertion helpers forward
#_sourceLocation(orfile:/line:) - Async tests cancel their pending work in teardown
- 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 — dummy/stub/spy/fake/mock, stubbing vs spying, classicist vs mockist, testing network requests, choosing a level
- Craft
- test-design.md — naming,
makeSUT, message-enum spies, DSLs, one assertion per test, snapshots, localization, isolation
- test-design.md — naming,
- Async
- async-testing.md — deterministic async tests, async spies, waiting without sleeping, implicit tasks, cancellation and test leaks
- Concurrency & Swift 6
- isolation-and-sendability.md —
@MainActorin tests, default isolation, Sendable requirements for suites/spies/arguments,@Observableview models
- isolation-and-sendability.md —
- Syntax
- swift-testing-syntax.md —
@Test,@Suite,#expect,#require,confirmation, parameterized tests, traits, tags
- swift-testing-syntax.md —
- Migration
- xctest-migration.md — the API translation table, interop modes,
trackForMemoryLeaksas a scoping trait, what stays XCTest
- xctest-migration.md — the API translation table, interop modes,
- Process
- tdd-process.md — red/green/refactor, commit discipline, legacy-code extraction, measuring test times