Swift Testing Pro

repository·main·Indexed 18 days ago

https://github.com/twostraws/swift-testing-agent-skill

An agent skill for AI coding assistants (Claude Code, Cursor, Gemini, Codex) designed to improve the quality of Swift 6.2+ testing code. It provides guidance on the Swift Testing framework, covering @Test and #expect/#require macros, parameterized testing, actor isolation, and async test patterns. The skill helps avoid common LLM pitfalls and provides idiomatic patterns to replace legacy XCTest practices.

Tokens
9.6K
Snippets
26
Records
40
Agent score
63%

What's inside swift-testing-agent-skill

  1. Overview of Swift Testing Pro capabilities

    main

    Swift Testing Pro is an agent skill designed for Swift 6.2+ that helps AI coding assistants write high-quality tests using the Swift Testing framework. It specifically targets common LLM mistakes by providing guidance on:

    • @Test macro usage
    • #expect and #require macros
    • Parameterized testing
    • Test traits
    • Exit tests
    • Confirmations
    • Patterns that keep tests small and fast
  2. Use the swift-testing-pro agent skill

    main

    The swift-testing-pro skill is designed for AI coding agents (like Claude Code) to write, review, and improve Swift Testing code. It focuses on modern Swift 6.2+ concurrency, correct API usage, and adherence to Swift Testing best practices.

    Capabilities

    • Writing Tests: Generate new unit and integration tests using Swift Testing.
    • Code Review: Identify genuine issues in existing Swift Testing code, reporting violations of core conventions, async testing best practices, or new feature usage.
    • Migration: Convert existing XCTest suites to the modern Swift Testing framework.

    Limitations

    • No UI Testing: Swift Testing does not support UI tests; you must continue using XCTest for UI testing tasks.
    • Toolchain Authority: While the skill uses the latest Swift features, always treat your local installed toolchain as the source of truth for API availability.
  3. Apply FIRST principles to unit test hygiene

    main

    To ensure high-quality unit tests, follow the FIRST acronym:

    • Fast: Tests should run quickly (dozens to thousands per second).
    • Isolated: Tests must not depend on external state or the execution order of other tests.
    • Repeatable: Tests must yield the same result every time they are run.
    • Self-verifying: Tests must unambiguously pass or fail without manual interpretation.
    • Timely: Tests should ideally be written alongside or before the production code.
  4. Use #require for preconditions and unwrapping

    main

    Use #require for checking assumptions at the start of a test. If a #require fails, it throws an error and stops the rest of the test immediately, preventing confusing secondary failures. This is ideal for setup steps where subsequent assertions would be meaningless if the precondition isn't met.

    Note: Using #require requires the test method to be marked as throws.

    #require is also a cleaner way to unwrap optionals:

    let value = try #require(someOptional)
    @Test func outstandingTasksStringIsPlural() throws {
        let sut = try createTestUser(projects: 3, itemsPerProject: 10)
        try #require(sut.projects.isEmpty == false)
        let rowTitle = sut.outstandingTasksString
        #expect(rowTitle == "30 items")
    }
  5. Ensure async work completes when using `confirmation(expectedCount:)`

    main

    When using confirmation(expectedCount:) to verify that an async function executes a specific number of times, the tested code must be fully finished before the confirmation() closure returns.

    Common Pitfall: Using a completion closure inside a Task without a way to track its completion will cause the test to fail, because confirmation() does not automatically wait for background tasks to finish.

    To fix this, use one of two patterns:

    1. Make the method async: Change the production code to use async/await so the test can await the call directly.
    2. Return a Task: If the code cannot be made async, have the method return the Task object so the test can await task.value before calling confirm().

    Note: confirmation(expectedCount: 0) is a valid way to ensure a specific event never occurs.

    // Pattern 1: Using async methods
    @Test func workerRunsThreeTimes() async {
        let worker = Worker()
        await confirmation(expectedCount: 3) { confirm in
            for _ in 0..<3 {
                await worker.run { /* work */ }
                confirm()
            }
        }
    }
    
    // Pattern 2: Awaiting a returned Task
    @Test func workerRunsThreeTimes() async {
        let worker = Worker()
        await confirmation(expectedCount: 3) { confirm in
            for _ in 0..<3 {
                let task = worker.run { /* work */ }
                await task.value
                confirm()
            }
        }
    }
  6. Use parameterized tests and handle collection zipping

    main

    Parameterized tests allow a single test to cover multiple inputs. Note that when passing two collections to arguments, Swift Testing produces a Cartesian product (every combination of both) rather than a pairwise zip.

    If you need to perform pairwise zipping (matching elements by index), wrap your collections in zip() before passing them to the arguments parameter.

  7. Implement test scoping traits for concurrency-safe configuration

    main

    Requires Swift 6.1 or later. Test scoping traits allow you to provide concurrency-safe access to shared configurations (like @TaskLocal properties) for specific tests or suites without risking shared mutable state.

    To implement a custom scope:

    1. Conform a struct to TestTrait and TestScoping.
    2. Implement provideScope(for:testCase:performing:) to set up the environment (e.g., using withValue for TaskLocals) and call the provided function().
    3. Extend Trait to make your custom trait accessible via the @Test macro.

    Multiple scopes can be applied in a comma-separated list: @Test(.firstScope, .secondScope). Scopes are applied in the order listed, meaning later scopes can overwrite values set by earlier ones.

    // 1. Define the trait and scope logic
    struct DefaultPlayerTrait: TestTrait, TestScoping {
        func provideScope(
            for test: Test,
            testCase: Test.Case?,
            performing function: () async throws -> Void
        ) async throws {
            let player = Player(name: "Natsuki Subaru")
            try await Player.$current.withValue(player) {
                try await function()
            }
        }
    }
    
    // 2. Extend Trait for easy access
    extension Trait where Self == DefaultPlayerTrait {
        static var defaultPlayer: Self { Self() }
    }
    
    // 3. Apply to tests
    @Test(.defaultPlayer) func welcomeScreenShowsName() {
        let result = createWelcomeScreen()
        #expect(result.contains("Natsuki Subaru"))
    }
  8. Test callback-based (pre-concurrency) code with `withCheckedContinuation`

    main

    When testing older code that uses completion handlers instead of async/await, use withCheckedContinuation to wrap the callback. This allows your test to await the result of the callback-based function.

    Requirement: The test must wait fully for the completion handler to be called before making assertions. You should perform your #expect assertions inside the continuation block before calling continuation.resume().

    @Test("Loading view model readings")
    func loadReadings() async {
        let viewModel = ViewModel()
    
        await withCheckedContinuation { continuation in
            viewModel.loadReadings { readings in
                #expect(readings.count >= 10)
                continuation.resume()
            }
        }
    }
  9. Request a Swift Testing code review

    main

    When asking the agent to review your Swift Testing code, it will organize findings by file. For every issue identified, the agent provides:

    1. The file name and relevant line number(s).
    2. The specific rule being violated.
    3. A brief before/after code snippet showing the fix.

    Files with no issues are skipped. The report concludes with a prioritized summary of the most impactful changes (e.g., High, Medium, Low priority).

    Example Review Output Format

    UserTests.swift

    Line 5: Use struct, not class, for test suites.

    // Before
    class UserTests: XCTestCase {
    
    // After
    struct UserTests {

    Line 12: Use #expect instead of XCTAssertEqual.

    // Before
    XCTAssertEqual(user.name, "Taylor")
    
    // After
    #expect(user.name == "Taylor")

    Summary

    1. Fundamentals (high): Test suite on line 5 should be a struct, not a class.
    2. Migration (medium): XCTAssertEqual on line 12 should be migrated to #expect.
  10. Install Swift Testing Pro for Codex, Gemini, Cursor, and others

    main

    For other AI coding assistants, use npx to add the skill. You can specify which agents to use and whether to install it for a single project or globally during the installation process.

    If you encounter npx: command not found, you must install Node.js via Homebrew:

    brew install node

    If Homebrew is not installed, install it from https://brew.sh.

    npx skills add https://github.com/twostraws/swift-testing-agent-skill --skill swift-testing-pro
  11. Expose hidden dependencies via Dependency Injection

    main

    Avoid hidden dependencies like URLSession or UserDefaults in production code. Instead, use dependency injection to allow for mocking in tests.

    Injecting URLSession via Protocol

    Wrapping URLSession in a protocol is the most robust method:

    protocol URLSessionProtocol {
        func data(from url: URL) async throws -> (Data, URLResponse)
    }
    
    extension URLSession: URLSessionProtocol { }
    
    // Production code
    func fetch(using session: any URLSessionProtocol = URLSession.shared) async throws {
        let (data, _) = try await session.data(from: url)
        // ...
    }

    Injecting UserDefaults

    To prevent tests from interfering with global state, inject a local UserDefaults instance with a unique suite name:

    let suite = "suite-\(UUID().uuidString)"
    let userDefaults = UserDefaults(suiteName: suite)
    defer { userDefaults?.removePersistentDomain(forName: suite) }
    protocol URLSessionProtocol {
        func data(from url: URL) async throws -> (Data, URLResponse)
    }
    
    extension URLSession: URLSessionProtocol { }
    
    func fetch(using session: any URLSessionProtocol = URLSession.shared) async throws {
        let (data, _) = try await session.data(from: url)
        // ...
    }