Install
npx skillscat add nonameplum/agent-skills/swift-unit-testing Install via the SkillsCat registry.
The skill guides LLM agents to create Swift unit tests using the Swift Testing framework, enforcing AAA structure, naming conventions, and isolation. It ensures deterministic, fast tests that avoid XCTest, flakiness, and external dependencies.
Swift Unit Testing: Agent Playbook
Audience: LLM coding agents generating or modifying Swift unit tests
Framework: Swift Testing (import Testing)
Quick Reference (Read First)
| Requirement | Rule |
|---|---|
| Framework | Swift Testing (@Test, #expect, #require) — never XCTest for new tests |
| Test structure | AAA pattern with // Arrange, // Act, // Assert comments |
| Naming | operation_condition_expectedResult — no "test" prefix, no "should" |
| SUT variable | Always sut — never store, context, container for the main subject |
| Helpers | make... prefix — placed at bottom of file |
| Dependencies | Fakes/stubs via closures — avoid assertion-heavy mocks |
| Isolation | Deterministic, in-memory, no network, no disk, no real time |
Non-Negotiables (NEVER Violate)
- NEVER use XCTest for new tests — use Swift Testing exclusively
- NEVER write flaky tests — no network, no real filesystem, no
Date(), noUUID() - NEVER combine multiple behaviors in one test — split into separate
@Testfunctions - NEVER skip AAA comments — every test must have
// Arrange,// Act,// Assert - NEVER use magic strings/numbers — use named constants or fixture values
- NEVER guess CLI commands — insert
> TODO:marker if unknown - NEVER modify unrelated code — touch only what the task requires
- NEVER invent abstractions — follow existing patterns in nearest neighbor tests
Workflow (Execute Every Time)
Step 1: Clarify Intent
Before writing any code, answer:
- What single behavior is being verified?
- What is the exact given / when / then?
- What is out of scope?
If unclear, stop and ask.
Step 2: Locate Insertion Point
Tests/
├── {ModuleName}Tests/ ← SwiftPM default (mirror source hierarchy)
│ ├── Test{Subject}.swift
│ └── TestSupport/
│ └── fakes, helpers
└── {AppName}Tests/ ← Xcode test target
└── Test{Subject}.swift- Find the closest existing test file under
Tests/or the test target folder - If no test file exists, create one following naming convention
- Mirror the source hierarchy if the repo already does so
- Check nearest neighbor tests for patterns before inventing structure
Step 3: Select Testing Approach
| Subject Type | Approach |
|---|---|
| Pure function/utility | Direct call + #expect |
| SwiftData schema (if used) | In-memory container + @Suite(.serialized) |
| SwiftData migration (if used) | File-based container (only when testing cross-instance migration) |
Step 4: Write the Test
Follow this exact structure:
import Testing
@testable import {ModuleName}
struct Test{Subject} {
@Test
func operation_condition_expectedResult() throws {
// Arrange
let sut = makeSut()
// Act
let result = sut.performOperation()
// Assert
#expect(result == expectedValue)
}
// MARK: - Helpers
private func makeSut() -> Subject {
Subject()
}
}Step 5: Verify
- Run only the relevant test(s) — not the full suite
- Confirm deterministic: run twice, same result
- Check for linter errors
Framework: Swift Testing
Import Statement
import Testing
@testable import {AppTarget}
### Test Container
Prefer `struct`. Use `class` only when `deinit` cleanup is required.
```swift
struct TestUserService {
@Test
func fetchUser_validId_returnsUser() async throws {
// ...
}
}Assertions
| Use Case | Macro |
|---|---|
| Boolean/equality | #expect(value == expected) |
| Unwrap optional | let x = try #require(optionalValue) |
| Verify throws | #expect(throws: SomeError.self) { try operation() } |
| Verify no throw | #expect(throws: Never.self) { try operation() } |
NEVER use XCTest assertions (XCTAssert*) in Swift Testing files.
Display Names
Use @Test("...") only when the function name cannot be expressive:
// ✅ Display name adds value
@Test("V0→V1 migration preserves user recordings")
func migrateFromV0_preservesRecordings() throws { }
// ❌ Display name duplicates function name — remove it
@Test("Save user persists to store")
func saveUser_persistsToStore() throws { }Naming Conventions
File Names
| Pattern | Example |
|---|---|
Test{Subject}.swift |
TestMigrationPlan.swift |
Test{Subject}+{Aspect}.swift |
TestModelContainer+Migration.swift |
Test Function Names
Pattern: operation_condition_expectedResult
// ✅ Good
func createContainer_withUnversionedStore_migratesSuccessfully()
func fetchUser_nonExistentId_returnsNil()
func onAppear_setsInitialFocus()
// ❌ Bad — verbose, uses "should", uses "test" prefix
func testThatWhenUserIsSavedThenItShouldBeFetched()Variable Names
// ✅ Always use `sut` for system under test
let sut = makeSut()
var sut = TestStore(...)
// ❌ Never use inconsistent names for SUT
let store = makeSut() // Bad
let context = ... // BadAAA Pattern (Required)
Every test MUST have these three comments:
@Test
func operation_succeeds() throws {
// Arrange
let sut = makeSut()
let input = Input.mock()
// Act
let result = sut.process(input)
// Assert
#expect(result.isSuccess)
}Async Coordination: Gate
Use a lightweight Gate to coordinate async tasks deterministically. This
avoids flaky timing with Task.sleep or Task.yield.
let gate = Gate()
let task = Task {
// Act
await gate.enter()
// Continue after the signal
}
// Arrange any prerequisites, then signal the task to proceed
gate.open()
_ = await task.valueimport Synchronization
public struct Gate: Sendable {
private enum State {
case closed
case open
case pending(UnsafeContinuation<Void, Never>)
}
private let state = ManagedCriticalState(State.closed)
public init() {}
public func open() {
state.withCriticalRegion { state -> UnsafeContinuation<Void, Never>? in
switch state {
case .closed:
state = .open
return nil
case .open:
return nil
case .pending(let continuation):
state = .closed
return continuation
}
}?.resume()
}
public func enter() async {
var other: UnsafeContinuation<Void, Never>?
await withUnsafeContinuation { (continuation: UnsafeContinuation<Void, Never>) in
state.withCriticalRegion { state -> UnsafeContinuation<Void, Never>? in
switch state {
case .closed:
state = .pending(continuation)
return nil
case .open:
state = .closed
return continuation
case .pending(let existing):
other = existing
state = .pending(continuation)
return nil
}
}?.resume()
}
other?.resume()
}
}About ManagedCriticalState:
It is a lightweight lock from Synchronization that protects a tiny critical
section. Keep the locked region small and never resume continuations inside it.
If you cannot use ManagedCriticalState, use a Mutex or an actor with the
same extract-then-resume pattern.
Guidelines:
- Use a
Gateper synchronization point. - Prefer explicit
enter()/open()over time-based delays.
Reference:
- AsyncAlgorithms Gate.swift
Memory Leak Detection Trait
Use a test trait to verify tracked objects are deallocated after each test.
This catches retain cycles without relying on timing.
import Testing
@Suite(.checkMemoryLeaks)
struct MyTests {
@Test
func myTest() async throws {
let sut = MyClass()
// ... exercise sut ...
trackForMemoryLeaks(sut)
}
}Implementation (drop into test target):
import Testing
public struct MemoryLeakCheckTrait: TestTrait, SuiteTrait, TestScoping {
public init() {}
public var isRecursive: Bool { true }
public func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @concurrent @Sendable () async throws -> Void
) async throws {
let tracker = LeakTracker()
try await LeakTracker.$current.withValue(tracker) {
try await function()
}
try tracker.verifyNoLeaks()
}
}
public extension Trait where Self == MemoryLeakCheckTrait {
static var checkMemoryLeaks: Self { Self() }
}
private final class LeakTracker: @unchecked Sendable {
@TaskLocal static var current = LeakTracker()
private var trackedInstances: [(closure: () -> AnyObject?, sourceLocation: SourceLocation)] = []
func track<T: AnyObject>(_ instance: T, sourceLocation: SourceLocation) {
trackedInstances.append(({ [weak instance] in instance }, sourceLocation))
}
func verifyNoLeaks() throws {
for tracked in trackedInstances {
#expect(
tracked.closure() == nil,
"Instance should have been deallocated. Potential memory leak.",
sourceLocation: tracked.sourceLocation
)
}
trackedInstances.removeAll()
}
}
public func trackForMemoryLeaks(
_ instance: AnyObject,
fileID: String = #fileID,
filePath: String = #filePath,
line: Int = #line,
column: Int = #column
) {
LeakTracker.current.track(
instance,
sourceLocation: SourceLocation(
fileID: fileID,
filePath: filePath,
line: line,
column: column
)
)
}Helper Patterns
These patterns apply to all tests — plain services, SwiftData, utilities, etc.
Single Return Value
When the test only needs the SUT:
// Plain service
private func makeSut() -> UserService {
UserService(repository: MockUserRepository())
}
Tuple Return
When you need SUT + collaborators you want to inspect/verify, return a labeled tuple:
@Test
func fetchUser_callsRepository() async throws {
// Arrange
let (sut, repository) = makeSut()
// Act
_ = try await sut.fetchUser(id: "123")
// Assert
#expect(repository.fetchCalledWith == "123")
}
// MARK: - Helpers
private func makeSut() -> (
sut: UserService,
repository: MockUserRepository
) {
let repository = MockUserRepository()
let service = UserService(repository: repository)
return (sut: service, repository: repository)
}TestEnvironment Struct
When the tuple becomes unwieldy (typically 3+ items, but use judgment based on readability), switch to a TestEnvironment struct.
Naming rules:
- Struct name:
TestEnvironment— neverSystemUnderTest - Main subject property:
sut— maintains consistency across all patterns - Variable name:
env— short and readable - Access pattern:
env.sutreads as "the environment's system under test"
@Test
func sync_updatesAllSystems() async throws {
// Arrange
let env = makeSut()
// Act
try await env.sut.performSync()
// Assert
#expect(env.repository.saveCalled)
#expect(env.cache.invalidateCalled)
#expect(env.analytics.trackedEvents.contains(.syncCompleted))
}
// MARK: - Helpers
private struct TestEnvironment {
let sut: SyncService
let repository: MockRepository
let cache: MockCacheManager
let analytics: MockAnalyticsClient
}
private func makeSut() -> TestEnvironment {
let repository = MockRepository()
let cache = MockCacheManager()
let analytics = MockAnalyticsClient()
let sut = SyncService(
repository: repository,
cache: cache,
analytics: analytics
)
return TestEnvironment(
sut: sut,
repository: repository,
cache: cache,
analytics: analytics
)
}Key points:
- Use tuple when it remains readable (often 2 items, sometimes 3)
- Switch to
TestEnvironmentwhen tuple becomes awkward to destructure or read - Always name the main subject
sut— consistent across single, tuple, and struct patterns - Access pattern:
env.sut.doSomething(),env.repository.fetchCalled - Keep
makeSut()as the factory name for consistency
SwiftData Testing
Required: Serial Execution
SwiftData has process-level global state. All SwiftData tests MUST run serially:
@Suite(.serialized)
final class TestSchemaMigration: SwiftDataTests {
// ...
}Container Types
| Purpose | Container Type |
|---|---|
| Schema compatibility | In-memory |
| Migration correctness | File-based (with cleanup) |
// In-memory (default)
private func makeInMemoryContainer() throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
return try ModelContainer(for: User.self, configurations: [config])
}Fixtures and Mocks
Fixture Pattern
Use version-agnostic mocks on current schema types:
// ✅ Good — version-agnostic
extension User {
static func mock(
id: String = TestValues.userID,
email: String = TestValues.userEmail
) -> User {
User(id: id, email: email)
}
}
private enum TestValues {
static let userID = "user-123"
static let userEmail = "user@example.com"
}
// ❌ Bad — tied to schema version
extension SchemaV1.User {
static func mock() -> SchemaV1.User { }
}Fakes Over Mocks
Prefer closures that return deterministic values:
// ✅ Fake — simple, deterministic
$0.apiClient.fetchUser = { _ in .mock() }
// ❌ Mock — complex, assertion-heavy
let mock = MockAPIClient()
mock.verify(.fetchUser, calledWith: userId)Helpers
Placement
Always at the bottom of the test file, after all @Test functions.
Naming
Always make... prefix:
private func makeSut() -> Subject { }
private func makeInMemoryContainer() throws -> ModelContainer { }
private func makeUser(email: String = "a@b.com") -> User { }Keep Minimal
Only expose parameters that tests commonly override:
// ✅ Good — configurable where needed
private func makeSut(
isLoading: Bool = false,
configure: ((inout State) -> Void)? = nil
) -> TestStore { }
// ❌ Bad — too many parameters
private func makeSut(
isLoading: Bool = false,
hasError: Bool = false,
userId: String = "",
userName: String = "",
// ... 10 more parameters
) -> TestStore { }Anti-Patterns (What NOT to Do)
| ❌ Don't | ✅ Do Instead |
|---|---|
testThatWhenUserIsSavedThenItCanBeFetched |
saveUser_canBeFetched |
Magic strings: #expect(error.code == "E001") |
Named constant: #expect(error.code == ErrorCode.notFound) |
| Large setup in each test | Extract to makeSut() helper |
| Multiple behaviors per test | One test per behavior |
| XCTest in Swift Testing files | #expect, #require |
setUp / tearDown |
init / deinit |
let store = makeSut() |
let sut = makeSut() |
// Act & Assert combined comment |
Separate // Act and // Assert |
Checklist Before Committing
- Uses Swift Testing (
import Testing,@Test,#expect) - Function name follows
operation_condition_expectedResult - Has
// Arrange,// Act,// Assertcomments - Uses
sutfor system under test - One behavior per test
- No shared mutable state between tests
- Deterministic (no network, no real disk, no real time)
- Helpers use
make...naming and are at bottom of file - No magic strings/numbers
- Runs in < 100ms