home / skills / kaakati / rails-enterprise-dev / xctest-patterns

This skill offers expert XCTest decision guidance for test type selection, mock design, and async testing strategies to improve reliability.

npx playbooks add skill kaakati/rails-enterprise-dev --skill xctest-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
11.4 KB
---
name: xctest-patterns
description: "Expert XCTest decisions for iOS/tvOS: test type selection trade-offs, mock vs stub vs spy judgment calls, async testing pitfalls, and coverage threshold calibration. Use when designing test strategy, debugging flaky tests, or deciding what to test. Trigger keywords: XCTest, unit test, integration test, UI test, mock, stub, spy, async test, expectation, test coverage, XCUITest, TDD, test pyramid"
version: "3.0.0"
---

# XCTest Patterns — Expert Decisions

Expert decision frameworks for testing choices. Claude knows XCTest syntax — this skill provides judgment calls for test strategy and mock design.

---

## Decision Trees

### Test Type Selection

```
What are you testing?
├─ Pure business logic (no I/O, no UI)
│  └─ Unit test
│     Fast, isolated, many of these
│
├─ Component interactions (services, repositories)
│  └─ Integration test
│     Test real interactions, fewer than unit
│
├─ User-visible behavior
│  └─ Does it require visual verification?
│     ├─ YES → Snapshot test or manual QA
│     └─ NO → UI test (XCUITest)
│        Slowest, fewest of these
│
└─ Performance characteristics
   └─ Performance test with measure {}
```

### Mock vs Stub vs Spy

```
What do you need from the test double?
├─ Just return canned data
│  └─ Stub
│     Simplest, no verification
│
├─ Verify interactions (was method called?)
│  └─ Spy
│     Records calls, verifiable
│
└─ Both return data AND verify calls
   └─ Mock (stub + spy)
      Most flexible, most complex
```

### When to Mock

```
Is this dependency...
├─ External (network, database, filesystem)?
│  └─ Always mock
│     Tests must be fast and deterministic
│
├─ Internal but slow or stateful?
│  └─ Mock if it makes test significantly faster
│
└─ Internal and fast?
   └─ Consider using real implementation
      Integration coverage > isolation purity
```

### Async Test Strategy

```
Is the async operation...
├─ Returning a value (async/await)?
│  └─ Use async test function
│     func testFetch() async throws { }
│
├─ Using completion handlers?
│  └─ Use XCTestExpectation
│     expectation.fulfill() in callback
│
└─ Publishing via Combine?
   └─ Use XCTestExpectation + sink
      Or use async-aware Combine helpers
```

---

## NEVER Do

### Test Design

**NEVER** test implementation details:
```swift
// ❌ Testing internal state
func testLogin() async {
    await sut.login(email: "[email protected]", password: "pass")

    XCTAssertEqual(sut.authService.callCount, 1)  // Implementation detail!
    XCTAssertEqual(sut.lastRequestTimestamp, Date())  // Internal state!
}

// ✅ Test observable behavior
func testLoginSuccess_SetsAuthenticatedState() async {
    await sut.login(email: "[email protected]", password: "pass")

    XCTAssertTrue(sut.isAuthenticated)  // Observable state
}
```

**NEVER** write tests that depend on execution order:
```swift
// ❌ Tests depend on each other
func testA_AddItem() {
    sut.add(item)  // sut state modified
    XCTAssertEqual(sut.count, 1)
}

func testB_RemoveItem() {
    // Depends on testA running first!
    sut.remove(item)
    XCTAssertEqual(sut.count, 0)
}

// ✅ Each test sets up its own state
func testRemoveItem() {
    sut.add(item)  // Explicit setup
    sut.remove(item)
    XCTAssertEqual(sut.count, 0)
}
```

**NEVER** use sleep() in tests:
```swift
// ❌ Flaky and slow
func testAsyncOperation() {
    sut.startOperation()
    Thread.sleep(forTimeInterval: 2.0)  // Arbitrary wait!
    XCTAssertTrue(sut.isComplete)
}

// ✅ Use expectations or async/await
func testAsyncOperation() async {
    await sut.startOperation()
    XCTAssertTrue(sut.isComplete)
}

// Or with expectations
func testAsyncOperation() {
    let expectation = expectation(description: "Operation completes")
    sut.startOperation { expectation.fulfill() }
    wait(for: [expectation], timeout: 5.0)
}
```

### Mock Design

**NEVER** create mocks that have real side effects:
```swift
// ❌ Mock does real work
final class MockNetworkService: NetworkServiceProtocol {
    func fetch(_ url: URL) async throws -> Data {
        try await URLSession.shared.data(from: url).0  // Real network!
    }
}

// ✅ Mock returns stubbed data
final class MockNetworkService: NetworkServiceProtocol {
    var stubbedData: Data = Data()
    var stubbedError: Error?

    func fetch(_ url: URL) async throws -> Data {
        if let error = stubbedError { throw error }
        return stubbedData
    }
}
```

**NEVER** verify everything in mocks:
```swift
// ❌ Over-specified — breaks if implementation changes
func testLogin() async {
    await sut.login()

    XCTAssertEqual(mockService.loginCallCount, 1)
    XCTAssertEqual(mockService.setTokenCallCount, 1)
    XCTAssertEqual(mockService.logAnalyticsCallCount, 1)
    XCTAssertEqual(mockService.updateUserCallCount, 1)
    // 10 more assertions...
}

// ✅ Verify only what matters for this test
func testLogin_StoresToken() async {
    await sut.login()
    XCTAssertNotNil(mockTokenStore.storedToken)
}
```

### Async Testing

**NEVER** forget to await async operations:
```swift
// ❌ Test passes before async work completes
func testFetchUser() {
    Task {
        await sut.fetchUser()  // Runs after test ends!
    }
    XCTAssertNotNil(sut.user)  // Always fails
}

// ✅ Make test function async
func testFetchUser() async {
    await sut.fetchUser()
    XCTAssertNotNil(sut.user)
}
```

**NEVER** forget to fulfill expectations:
```swift
// ❌ Test hangs if error path doesn't fulfill
func testNetworkCall() {
    let expectation = expectation(description: "Call completes")

    sut.fetch { result in
        if case .success = result {
            expectation.fulfill()
        }
        // Error case doesn't fulfill — test hangs!
    }

    wait(for: [expectation], timeout: 5.0)
}

// ✅ Fulfill in all paths
func testNetworkCall() {
    let expectation = expectation(description: "Call completes")

    sut.fetch { result in
        // Always fulfill, assert after
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 5.0)
    // Assert on result here
}
```

### UI Testing

**NEVER** use fixed delays in UI tests:
```swift
// ❌ Flaky — element might appear faster or slower
func testLoginFlow() {
    app.buttons["login"].tap()
    Thread.sleep(forTimeInterval: 3.0)
    XCTAssertTrue(app.staticTexts["Welcome"].exists)
}

// ✅ Use waitForExistence
func testLoginFlow() {
    app.buttons["login"].tap()
    let welcome = app.staticTexts["Welcome"]
    XCTAssertTrue(welcome.waitForExistence(timeout: 5.0))
}
```

**NEVER** rely on element positions:
```swift
// ❌ Breaks if UI layout changes
let firstButton = app.buttons.element(boundBy: 0)

// ✅ Use accessibility identifiers
let loginButton = app.buttons["loginButton"]
```

---

## Essential Patterns

### Structured Mock with Spy

```swift
final class MockUserService: UserServiceProtocol {
    // Stubs
    var stubbedUser: User?
    var stubbedError: Error?

    // Spy tracking
    private(set) var fetchUserCallCount = 0
    private(set) var fetchUserLastId: String?

    func fetchUser(id: String) async throws -> User {
        fetchUserCallCount += 1
        fetchUserLastId = id

        if let error = stubbedError { throw error }
        guard let user = stubbedUser else {
            throw MockError.notConfigured
        }
        return user
    }

    // Verification helpers
    func verify(fetchUserCalledWith id: String) -> Bool {
        fetchUserLastId == id
    }
}
```

### ViewModel Test Pattern

```swift
@MainActor
final class UserViewModelTests: XCTestCase {
    var sut: UserViewModel!
    var mockService: MockUserService!

    override func setUp() {
        super.setUp()
        mockService = MockUserService()
        sut = UserViewModel(userService: mockService)
    }

    override func tearDown() {
        sut = nil
        mockService = nil
        super.tearDown()
    }

    // Test initial state
    func testInitialState() {
        XCTAssertNil(sut.user)
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.errorMessage)
    }

    // Test success path
    func testFetchUser_Success() async {
        mockService.stubbedUser = User(id: "1", name: "John")

        await sut.fetchUser(id: "1")

        XCTAssertEqual(sut.user?.name, "John")
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.errorMessage)
    }

    // Test error path
    func testFetchUser_Error() async {
        mockService.stubbedError = NetworkError.timeout

        await sut.fetchUser(id: "1")

        XCTAssertNil(sut.user)
        XCTAssertFalse(sut.isLoading)
        XCTAssertNotNil(sut.errorMessage)
    }
}
```

### Test Data Builder

```swift
final class UserBuilder {
    private var id = "test-id"
    private var name = "Test User"
    private var email = "[email protected]"
    private var isActive = true

    func withId(_ id: String) -> Self {
        self.id = id
        return self
    }

    func withName(_ name: String) -> Self {
        self.name = name
        return self
    }

    func inactive() -> Self {
        self.isActive = false
        return self
    }

    func build() -> User {
        User(id: id, name: name, email: email, isActive: isActive)
    }
}

// Usage
let activeUser = UserBuilder().build()
let inactiveUser = UserBuilder().inactive().build()
let specificUser = UserBuilder().withId("123").withName("John").build()
```

### Async Expectation Helper

```swift
extension XCTestCase {
    func awaitPublisher<T: Publisher>(
        _ publisher: T,
        timeout: TimeInterval = 1.0,
        file: StaticString = #file,
        line: UInt = #line
    ) throws -> T.Output where T.Failure == Never {
        var result: T.Output?
        let expectation = expectation(description: "Awaiting publisher")

        let cancellable = publisher.sink { value in
            result = value
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: timeout)
        cancellable.cancel()

        return try XCTUnwrap(result, file: file, line: line)
    }
}
```

---

## Quick Reference

### Test Pyramid Distribution

| Test Type | Quantity | Speed | Reliability |
|-----------|----------|-------|-------------|
| Unit | Many (70%) | Fast | High |
| Integration | Some (20%) | Medium | Medium |
| UI | Few (10%) | Slow | Lower |

### Coverage Thresholds by Layer

| Layer | Target | Rationale |
|-------|--------|-----------|
| Domain/Business Logic | 90%+ | Critical correctness |
| Services/Repositories | 85% | Error handling matters |
| ViewModels | 70-80% | State transitions |
| Views | Don't measure | Visual QA instead |

### Assertion Cheat Sheet

| Check | Assertion |
|-------|-----------|
| Equality | `XCTAssertEqual(a, b)` |
| Nil | `XCTAssertNil(x)` / `XCTAssertNotNil(x)` |
| Boolean | `XCTAssertTrue(x)` / `XCTAssertFalse(x)` |
| Throws | `XCTAssertThrowsError(try expr)` |
| No throw | `XCTAssertNoThrow(try expr)` |
| Fail | `XCTFail("message")` |

### Red Flags

| Smell | Problem | Fix |
|-------|---------|-----|
| Tests run > 10s | Too slow | More mocking, fewer UI tests |
| Tests fail randomly | Flaky | Remove timing dependencies |
| setUp() is huge | Tests coupled | Extract builders/helpers |
| Mock verifies everything | Over-specified | Only verify what matters |
| Tests share state | Order-dependent | Fresh sut in setUp() |
| sleep() in tests | Unreliable | Expectations or async |
| 100% coverage goal | Chasing metrics | Focus on behavior coverage |

Overview

This skill provides expert guidance for XCTest decisions across iOS and tvOS projects. It helps choose test types, design test doubles (mock/stub/spy), avoid async and UI pitfalls, and set sensible coverage targets. Use it to shape a pragmatic, maintainable test strategy and to debug flaky tests.

How this skill works

The skill evaluates the testing goal and recommends the appropriate test layer (unit, integration, UI) and distribution within the test pyramid. It advises when to use stubs, spies, or mocks, prescribes async testing patterns (async/await, XCTestExpectation, Combine helpers), and flags anti-patterns to avoid. It also suggests coverage targets per layer and concrete patterns like structured mocks, test data builders, and publisher await helpers.

When to use it

  • Designing or revising a test strategy for a new or existing feature
  • Deciding whether to mock a dependency or use real implementation
  • Debugging flaky tests caused by timing, shared state, or UI waits
  • Choosing the right async testing approach for callbacks, async/await, or Combine
  • Setting realistic coverage targets by layer (domain, services, view models)

Best practices

  • Prefer unit tests for pure business logic; keep them fast and numerous
  • Mock external I/O always; prefer real fast internal implementations for meaningful integration
  • Verify only the observable behavior that matters for the test, not internal implementation details
  • Use async test functions or XCTestExpectation — never sleep() or fire-and-forget Tasks
  • Use accessibility identifiers and waitForExistence for UI tests instead of fixed delays

Example use cases

  • Decide between a unit test and an integration test for a service that talks to a local cache and remote API
  • Design a MockUserService that returns stubbed data and records call counts for verification
  • Convert flaky UI tests that used Thread.sleep into robust waits using waitForExistence
  • Write async tests for Combine publishers using an awaitPublisher helper on XCTestCase
  • Set coverage targets: 90%+ for domain logic, ~85% for services, 70–80% for view models

FAQ

When should I use a spy instead of a mock?

Use a spy when you only need to record and assert interactions (calls, arguments) without complex stubbing. Use a mock when you must both return canned data and verify interactions.

How do I avoid flaky async tests?

Make tests await async functions or fulfill XCTestExpectations in every code path. Avoid sleep() and ensure the test asserts only after the expectation is fulfilled or the await completes.