nonameplum

Swift Unit Testing: Agent Playbook

- [ ] Runs in < 100ms

nonameplum 17 2 Updated 7mo ago
GitHub

Install

npx skillscat add nonameplum/agent-skills/swift-unit-testing

Install via the SkillsCat registry.

About this skill

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.

SKILL.md

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)

  1. NEVER use XCTest for new tests — use Swift Testing exclusively
  2. NEVER write flaky tests — no network, no real filesystem, no Date(), no UUID()
  3. NEVER combine multiple behaviors in one test — split into separate @Test functions
  4. NEVER skip AAA comments — every test must have // Arrange, // Act, // Assert
  5. NEVER use magic strings/numbers — use named constants or fixture values
  6. NEVER guess CLI commands — insert > TODO: marker if unknown
  7. NEVER modify unrelated code — touch only what the task requires
  8. 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 = ...        // Bad

AAA 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.value
import 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 Gate per synchronization point.
  • Prefer explicit enter()/open() over time-based delays.

Reference:


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 — never SystemUnderTest
  • Main subject property: sut — maintains consistency across all patterns
  • Variable name: env — short and readable
  • Access pattern: env.sut reads 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 TestEnvironment when 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, // Assert comments
  • Uses sut for 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