eovidiu

ios-tdd-expert

2
0
# Install this skill:
npx skills add eovidiu/agents-skills --skill "ios-tdd-expert"

Install specific skill from multi-skill repository

# Description

Pragmatic Test-Driven Development skill for iOS engineers building native applications with Swift. Focus on shipping better code faster through behavioral testing, not dogma. Covers XCTest, Swift Testing framework (@Test, #expect), XCUITest for touch-based iOS interfaces (tap, swipe, scroll), protocol-based mocking, MVVM testability, async/await patterns, Combine testing, snapshot testing across device sizes, and performAccessibilityAudit (iOS 17+). Use when writing tests before implementation, testing ViewModels, SwiftUI views, UIKit controllers, services, navigation, or setting up testing infrastructure. Emphasizes testing what matters and skipping what doesn't.

# SKILL.md


name: ios-tdd-expert
description: Pragmatic Test-Driven Development skill for iOS engineers building native applications with Swift. Focus on shipping better code faster through behavioral testing, not dogma. Covers XCTest, Swift Testing framework (@Test, #expect), XCUITest for touch-based iOS interfaces (tap, swipe, scroll), protocol-based mocking, MVVM testability, async/await patterns, Combine testing, snapshot testing across device sizes, and performAccessibilityAudit (iOS 17+). Use when writing tests before implementation, testing ViewModels, SwiftUI views, UIKit controllers, services, navigation, or setting up testing infrastructure. Emphasizes testing what matters and skipping what doesn't.


Pragmatic TDD for iOS

Overview

Pragmatic iOS TDD Expert provides senior-level guidance for iOS engineers who write tests before implementation — not because it's dogma, but because it genuinely ships better code faster. This skill focuses on behavior-driven testing using XCTest and Swift Testing, realistic dependency mocking through protocols, and a ViewModel-centric integration approach that maximizes confidence while minimizing brittleness.

Use this skill when building or testing iOS applications with TDD, setting up testing infrastructure, reviewing code for test quality, or needing guidance on what to test (and what to skip).

Core Philosophy

Test to Ship Fast, Not to Reach 100%

Write tests that provide value. Skip tests that don't.

Test what matters:
- ViewModel state transitions and logic
- Model validation and business rules
- Service layer with mocked dependencies
- Error handling and recovery
- Data transformations and formatters
- Async workflows and state machines
- Navigation logic and deep link resolution
- Critical user flows (UI tests, sparingly)
- Accessibility compliance (iOS 17+)
- Visual consistency across device sizes (snapshot tests)

Skip what doesn't:
- SwiftUI view body composition
- Apple framework internals
- Auto Layout constraints and styling
- Trivial computed properties
- UIKit/SwiftUI rendering details

Reference: references/testing-philosophy.md - Complete pragmatic TDD philosophy, when to test vs. skip, Red-Green-Refactor cycle, and coverage strategies.

The iOS Testing Pyramid

     /\
    /  \     UI Tests (5-10%) - Critical flows only (XCUITest, touch-based)
   /____\
  /      \   Integration Tests (60-70%) - Your sweet spot
 /        \  ViewModel + Services + mocked dependencies
/__________\
             Unit Tests (20-30%) - Pure functions + models

Focus on integration tests. This is where iOS app value lives.

The TDD Cycle

Red -> Green -> Refactor

1. RED: Write a Failing Test

// Using Swift Testing
@Test func calculatesShippingCost() {
    let order = Order(items: [Item(weight: 2.5)], destination: .domestic)
    #expect(order.shippingCost == 7.50)
}
// Compilation error - shippingCost doesn't exist yet

2. GREEN: Make It Pass (Minimal Code)

extension Order {
    var shippingCost: Double {
        let totalWeight = items.reduce(0) { $0 + $1.weight }
        return destination == .domestic ? totalWeight * 3.0 : totalWeight * 8.0
    }
}
// Test passes

3. REFACTOR: Clean Up (Tests Still Pass)

extension Order {
    var shippingCost: Double {
        let totalWeight = totalItemWeight
        return shippingRate(for: destination) * totalWeight
    }

    private var totalItemWeight: Double {
        items.reduce(0) { $0 + $1.weight }
    }

    private func shippingRate(for destination: Destination) -> Double {
        switch destination {
        case .domestic: return 3.0
        case .international: return 8.0
        }
    }
}
// Tests still pass - better code, same behavior

Quick Start

1. Set Up Testing Infrastructure

Use the setup script:

bash scripts/setup-testing.sh

Or manually add a test target in Xcode:
- File -> New -> Target -> iOS Unit Testing Bundle
- File -> New -> Target -> iOS UI Testing Bundle

2. Choose Your Testing Framework

XCTest (mature, Xcode-integrated):

import XCTest
@testable import MyApp

final class UserViewModelTests: XCTestCase {
    func testLoadsUsers() async {
        // ...
    }
}

Swift Testing (modern, expressive):

import Testing
@testable import MyApp

struct UserViewModelTests {
    @Test func loadsUsers() async {
        // ...
    }
}

Reference: references/xctest-reference.md - Complete comparison of XCTest vs Swift Testing, migration guide, and when to use each.

3. Set Up Dependency Injection

Define protocols for external dependencies:

protocol UserServiceProtocol: Sendable {
    func fetchUsers() async throws -> [User]
    func createUser(_ user: User) async throws -> User
}

Create mock implementations for tests:

final class MockUserService: UserServiceProtocol {
    var usersToReturn: [User] = []
    var errorToThrow: Error?

    func fetchUsers() async throws -> [User] {
        if let error = errorToThrow { throw error }
        return usersToReturn
    }

    func createUser(_ user: User) async throws -> User {
        if let error = errorToThrow { throw error }
        return user
    }
}

4. Run Tests from CLI

# iOS Simulator tests
xcodebuild test \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -resultBundlePath TestResults.xcresult

# With code coverage
xcodebuild test \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -enableCodeCoverage YES

# Extract coverage report
xcrun xccov view --report TestResults.xcresult

# SPM tests (for framework/library projects)
swift test --parallel

# SPM with coverage
swift test --enable-code-coverage

5. Start Testing!

Use templates for common patterns:
- assets/templates/ViewModelTests.swift - ViewModel testing
- assets/templates/ServiceTests.swift - Service/networking layer
- assets/templates/NavigationTests.swift - Navigation and deep links
- assets/templates/XCUITestTemplate.swift - Touch-based UI tests
- assets/templates/SnapshotTests.swift - Visual regression across devices
- assets/templates/AccessibilityTests.swift - Accessibility audits (iOS 17+)
- assets/templates/TestHelpers.swift - Common utilities

When to Use This Skill

Trigger this skill when:
- Writing tests before implementing iOS features (TDD)
- Testing ViewModels and their state transitions
- Testing service layers with mocked dependencies
- Writing XCUITest UI tests for iOS (touch: tap, swipe, scroll)
- Testing async/await and Combine code
- Testing navigation logic and deep link resolution
- Snapshot testing across iPhone SE, iPhone 16, iPad
- Running accessibility audits with performAccessibilityAudit (iOS 17+)
- Setting up testing infrastructure for iOS projects
- Reviewing code for testability
- Deciding what to test vs. skip
- Debugging flaky or failing tests

Testing Patterns by Use Case

1. ViewModel Testing (Integration Layer)

Test logic through ViewModels - this is your primary testing surface.

@MainActor
final class UserListViewModelTests: XCTestCase {
    var mockService: MockUserService!
    var viewModel: UserListViewModel!

    override func setUp() {
        mockService = MockUserService()
        viewModel = UserListViewModel(service: mockService)
    }

    func testLoadsAndDisplaysUsers() async {
        mockService.usersToReturn = [
            User(id: "1", name: "Alice"),
            User(id: "2", name: "Bob")
        ]

        await viewModel.loadUsers()

        XCTAssertEqual(viewModel.users.count, 2)
        XCTAssertEqual(viewModel.users.first?.name, "Alice")
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertNil(viewModel.errorMessage)
    }

    func testShowsErrorOnFailure() async {
        mockService.errorToThrow = ServiceError.networkUnavailable

        await viewModel.loadUsers()

        XCTAssertTrue(viewModel.users.isEmpty)
        XCTAssertNotNil(viewModel.errorMessage)
        XCTAssertFalse(viewModel.isLoading)
    }
}

Reference: references/xctest-reference.md - XCTest and Swift Testing patterns for ViewModels.

Template: assets/templates/ViewModelTests.swift

2. Service Layer Testing

Mock the network, test real service logic.

final class UserServiceTests: XCTestCase {
    func testFetchUsersDecodesResponse() async throws {
        let mockSession = MockURLSession(
            data: usersJSON.data(using: .utf8)!,
            response: HTTPURLResponse(statusCode: 200)
        )
        let service = UserService(session: mockSession)

        let users = try await service.fetchUsers()

        XCTAssertEqual(users.count, 2)
        XCTAssertEqual(users.first?.name, "Alice")
    }

    func testFetchUsersThrowsOnServerError() async {
        let mockSession = MockURLSession(
            data: Data(),
            response: HTTPURLResponse(statusCode: 500)
        )
        let service = UserService(session: mockSession)

        do {
            _ = try await service.fetchUsers()
            XCTFail("Expected error")
        } catch {
            XCTAssertTrue(error is ServiceError)
        }
    }
}

Reference: references/mocking-patterns.md - Protocol mocking, URLProtocol stubs, and dependency injection patterns.

Template: assets/templates/ServiceTests.swift

3. XCUITest for iOS (Touch-Based)

Test critical user flows through the actual UI with touch gestures.

final class LoginUITests: XCTestCase {
    let app = XCUIApplication()

    override func setUp() {
        continueAfterFailure = false
        app.launchArguments = ["--ui-testing"]
        app.launch()
    }

    func testLoginFlowShowsDashboard() {
        let emailField = app.textFields["Email"]
        emailField.tap()
        emailField.typeText("[email protected]")

        let passwordField = app.secureTextFields["Password"]
        passwordField.tap()
        passwordField.typeText("password123")

        app.buttons["Sign In"].tap()

        XCTAssertTrue(
            app.staticTexts["Welcome, Alice"].waitForExistence(timeout: 5)
        )
    }

    func testPullToRefreshUpdatesContent() {
        let firstCell = app.cells.firstMatch
        XCTAssertTrue(firstCell.waitForExistence(timeout: 5))

        let start = firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
        let end = firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 5.0))
        start.press(forDuration: 0, thenDragTo: end)

        // Assert refresh indicator appeared and content updated
    }
}

Reference: references/xcuitest-guide.md - XCUITest patterns for iOS touch interactions.

Template: assets/templates/XCUITestTemplate.swift

4. Navigation Testing

Test navigation logic without UI.

@MainActor
func testDeepLinkNavigatesToProfile() {
    let router = AppRouter()
    let url = URL(string: "myapp://profile/123")!

    router.handle(deepLink: url)

    XCTAssertEqual(router.path.count, 1)
    if case .profile(let id) = router.path.last {
        XCTAssertEqual(id, "123")
    } else {
        XCTFail("Expected profile route")
    }
}

Template: assets/templates/NavigationTests.swift

5. Snapshot Testing (Multi-Device)

Test visual consistency across iOS device sizes.

func testProfileViewiPhoneSE() {
    let view = ProfileView(user: .preview)
    assertSnapshot(
        of: view,
        as: .image(layout: .device(config: .iPhoneSe)),
        record: false
    )
}

Reference: references/snapshot-testing.md - swift-snapshot-testing for iOS devices.

Template: assets/templates/SnapshotTests.swift

6. Accessibility Testing (iOS 17+)

Automated accessibility audits.

func testHomeScreenAccessibility() throws {
    let app = XCUIApplication()
    app.launch()
    try app.performAccessibilityAudit()
}

Reference: references/accessibility-testing.md - performAccessibilityAudit guide.

Template: assets/templates/AccessibilityTests.swift

Key Principles

1. Test Behavior, Not Implementation

// BAD - Tests internal state
XCTAssertTrue(viewModel.internalFlag)

// GOOD - Tests observable behavior
XCTAssertEqual(viewModel.displayTitle, "2 items in cart")

2. Mock Externally, Integrate Internally

// GOOD - Mock external dependency via protocol
let mockService = MockUserService(users: testUsers)
let viewModel = UserListViewModel(service: mockService)

// BAD - Mock your own ViewModel
let mockViewModel = MockUserListViewModel() // Don't do this

3. Use Accessibility Identifiers, Not View Hierarchy

// GOOD - Stable identifier
app.buttons["submitOrder"]

// BAD - Fragile hierarchy traversal
app.scrollViews.firstMatch.otherElements.buttons.element(boundBy: 2)

4. Use async/await, Not Expectations (When Possible)

// GOOD - Direct async
@MainActor
func testLoadsData() async {
    await viewModel.loadData()
    XCTAssertEqual(viewModel.items.count, 3)
}

// OK - XCTestExpectation for callback-based APIs
func testLegacyCallback() {
    let expectation = expectation(description: "Callback fired")
    legacyAPI.fetch { result in
        expectation.fulfill()
    }
    wait(for: [expectation], timeout: 5)
}

5. Touch-Based Interactions for iOS

// iOS uses tap, swipe, scroll — NOT click
app.buttons["Submit"].tap()
app.cells.firstMatch.swipeLeft()
app.tables.firstMatch.swipeUp()

Anti-Patterns to Avoid

Testing SwiftUI View Body

// BAD - Inspecting view hierarchy
let view = ContentView()
// Trying to assert on body structure

// GOOD - Test the ViewModel that drives the view
let vm = ContentViewModel()
await vm.load()
XCTAssertEqual(vm.title, "Dashboard")

Testing Apple Frameworks

// BAD
func testNavigationStackPushesView() { /* ... */ }
func testStatePropertyWrapperUpdates() { /* ... */ }
func testCombinePublisherEmits() { /* ... */ }

// GOOD - Test YOUR logic
func testViewModelTransitionsToDetailState() async { /* ... */ }

Using Sleep Instead of Expectations

// BAD
Thread.sleep(forTimeInterval: 2.0)

// GOOD
await fulfillment(of: [expectation], timeout: 5)

// GOOD
await viewModel.loadData()

Giant Test Methods

// BAD - 200 lines testing everything
func testCompleteUserJourney() { /* ... */ }

// GOOD - Focused tests
func testUserCanLogIn() async { /* ... */ }
func testUserCanBrowseProducts() async { /* ... */ }
func testUserCanCheckout() async { /* ... */ }

Decision Tree: What to Test

Is it a pure function or model logic?
|-- YES -> Unit test
|-- NO  |

Does it involve ViewModel state transitions or service integration?
|-- YES -> Integration test (ViewModel + mocked services)
|-- NO  |

Is it a critical user flow spanning multiple screens?
|-- YES -> XCUITest UI test (touch-based)
|-- NO  |

Does it need visual consistency across device sizes?
|-- YES -> Snapshot test (iPhone SE, iPhone 16, iPad)
|-- NO  |

Does it need accessibility compliance?
|-- YES -> performAccessibilityAudit (iOS 17+)
|-- NO  -> Probably don't test it

Speed Matters

Fast tests = fast feedback = fast iteration.

Optimize for speed:
- Unit tests: <50ms each
- ViewModel integration tests: <200ms each
- XCUITest: <10 seconds each
- Snapshot tests: <500ms each

Run in parallel:

# Xcode parallel testing
xcodebuild test \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 16' \
    -parallel-testing-enabled YES

# SPM parallel testing (default)
swift test --parallel

Resources

references/

Comprehensive documentation loaded as needed:

  • testing-philosophy.md - When to test, what to skip, Red-Green-Refactor, coverage strategies
  • xctest-reference.md - XCTest and Swift Testing framework patterns
  • xcuitest-guide.md - XCUITest for iOS: touch gestures, queries, screenshots
  • mocking-patterns.md - Protocol mocking, Combine publishers, @Observable, URLProtocol
  • snapshot-testing.md - swift-snapshot-testing across device sizes and traits
  • accessibility-testing.md - performAccessibilityAudit (iOS 17+), VoiceOver strategies

scripts/

Setup utilities:

  • setup-testing.sh - Set up test targets, verify simulators, smoke test

assets/templates/

Copy-paste templates for common scenarios:

  • ViewModelTests.swift - ViewModel testing with mocked services
  • ServiceTests.swift - Service/networking layer with URLProtocol
  • NavigationTests.swift - Navigation logic and deep links
  • XCUITestTemplate.swift - Touch-based UI tests
  • SnapshotTests.swift - Visual regression across device sizes
  • AccessibilityTests.swift - Accessibility audit tests (iOS 17+)
  • TestHelpers.swift - Common test utilities and factories

Summary

TDD is a tool for shipping better iOS apps faster.

  • 70% integration tests - ViewModels + services with mocked dependencies
  • 25% unit tests - Pure functions + model logic
  • 5% UI tests - Critical flows with XCUITest (touch-based)

Test behavior, not implementation.
Mock externally, integrate internally.
Touch-first: tap, swipe, scroll — never click.
Speed is a feature.

Write tests that make you ship with confidence, not tests that make you scared to refactor.

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.