Claude Code Plugins

Community-maintained marketplace

Feedback

swift-testing-conventions

@benaor/claude-config
2
0

Testing conventions for Swift/SwiftUI projects using Clean Architecture. Covers Swift Testing framework for unit/integration tests, XCUITest for UI and E2E tests, manual protocol-based mocking, async testing patterns, and test organization. Use when writing tests, reviewing test code, setting up test infrastructure, or discussing testing strategy in Swift projects.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name swift-testing-conventions
description Testing conventions for Swift/SwiftUI projects using Clean Architecture. Covers Swift Testing framework for unit/integration tests, XCUITest for UI and E2E tests, manual protocol-based mocking, async testing patterns, and test organization. Use when writing tests, reviewing test code, setting up test infrastructure, or discussing testing strategy in Swift projects.

Swift Testing Conventions

Testing conventions for Swift/SwiftUI projects with Clean Architecture (domain + app layers).

Testing Pyramid

        ┌─────────┐
        │   E2E   │  ← {app}E2ETests (XCUITest) - Full user flows
       ┌┴─────────┴┐
       │    UI     │  ← {app}UITests (XCUITest) - Component interactions
      ┌┴───────────┴┐
      │ Integration │  ← Swift Testing - UseCases + real adapters, ViewModels + repos
     ┌┴─────────────┴┐
     │     Unit      │  ← Swift Testing - Entities, UseCases, ViewModels in isolation
     └───────────────┘

Distribution target: Unit > Integration > UI > E2E

FIRST Principles

Principle Description
Fast Unit tests < 100ms, Integration < 500ms
Independent No shared state, any order execution
Repeatable Same result every run, no external dependencies
Self-validating Pass/fail without manual inspection
Timely Written with or before production code

File Organization

root/
├── domain/
├── domainTests/                     # Swift Testing target
│   ├── {BoundedContext}/
│   │   ├── Entities/                # Entity tests
│   │   ├── UseCases/                # UseCase tests
│   │   └── Stubs/                   # Context-specific stubs
│   └── TestUtils/
│       ├── Builders/                # Entity builders
│       └── Stubs/                   # Shared stubs
├── {app}/
├── {app}Tests/                      # Swift Testing target
│   ├── ViewModels/
│   └── TestUtils/
│       ├── Builders/
│       └── Stubs/
├── {app}UITests/                    # XCUITest target - Component tests
│   ├── Components/
│   ├── Pages/                       # Page Objects
│   └── Helpers/
└── {app}E2ETests/                   # XCUITest target - Flow tests
    ├── Flows/
    ├── Pages/
    └── Helpers/

Naming Conventions

Type Pattern Example
Test file {ClassName}Tests.swift SessionViewModelTests.swift
Stub {Protocol}Stub.swift SessionRepositoryStub.swift
Builder {Entity}Builder.swift SessionBuilder.swift
Page Object {Screen}Page.swift HomePage.swift

Test Anatomy (Swift Testing)

Structure

import Testing
@testable import Domain

@Suite("SessionUseCase")
struct SessionUseCaseTests {
    
    // MARK: - Dependencies (recreated per test)
    var repository: SessionRepositoryStub
    var monitor: MonitorStub
    var sut: GetSessionUseCase
    
    init() {
        repository = SessionRepositoryStub()
        monitor = MonitorStub()
        sut = GetSessionUseCase(repository: repository, monitor: monitor)
    }
    
    @Test("should return session when repository has active session")
    func shouldReturnSession_whenRepositoryHasActiveSession() async {
        // Given
        let expected = SessionBuilder().withStatus(.active).build()
        repository.getSessionResult = .success(expected)
        
        // When
        let result = await sut.execute()
        
        // Then
        #expect(result == .success(expected))
    }
    
    @Test("should return nil when repository is empty")
    func shouldReturnNil_whenRepositoryIsEmpty() async {
        // Given
        repository.getSessionResult = .success(nil)
        
        // When
        let result = await sut.execute()
        
        // Then
        #expect(try result.get() == nil)
    }
}

Naming Convention

Pattern: shouldExpectedBehavior_whenCondition()

// ✅ Good
func shouldReturnError_whenNetworkFails() async
func shouldUpdateState_whenUserTapsStart()
func shouldCallRepository_whenExecuted() async

// ❌ Bad
func testGetSession()           // No behavior described
func test_session_returns()     // Unclear condition
func itWorks()                  // Meaningless

Assertions

// Basic
#expect(value == expected)
#expect(value != nil)
#expect(array.isEmpty)

// Optionals
#expect(optional == nil)
let unwrapped = try #require(optional)  // Fails test if nil

// Errors
#expect(throws: ValidationError.self) {
    try sut.validate(invalidInput)
}

// Boolean
#expect(user.isActive)
#expect(!session.isExpired)

Test Doubles

Types by Layer

Layer Test Double Purpose
Domain (Entities, UseCases) Stub Return predefined data
UI (ViewModels) Stub Return predefined data
Infrastructure (Adapters) Spy/Mock Verify interactions

Protocol Stub Pattern

// Port (in production code)
protocol SessionRepositoryProtocol {
    func getSession() async -> Result<Session?, RepositoryError>
    func saveSession(_ session: Session) async -> Result<Void, RepositoryError>
}

// Stub (in test code)
final class SessionRepositoryStub: SessionRepositoryProtocol {
    // Configurable results
    var getSessionResult: Result<Session?, RepositoryError> = .success(nil)
    var saveSessionResult: Result<Void, RepositoryError> = .success(())
    
    // Call tracking (Spy capability)
    private(set) var saveSessionCalls: [Session] = []
    
    func getSession() async -> Result<Session?, RepositoryError> {
        getSessionResult
    }
    
    func saveSession(_ session: Session) async -> Result<Void, RepositoryError> {
        saveSessionCalls.append(session)
        return saveSessionResult
    }
}

When to Use Each

// Stub: Testing output based on input
@Test func shouldDisplayError_whenRepositoryFails() async {
    repository.getSessionResult = .failure(.notFound)  // Stub behavior
    
    await sut.loadSession()
    
    #expect(sut.errorMessage != nil)
}

// Spy: Verifying side effects
@Test func shouldSaveSession_whenUserTapsConfirm() async {
    await sut.confirmSession()
    
    #expect(repository.saveSessionCalls.count == 1)  // Spy verification
    #expect(repository.saveSessionCalls.first?.status == .confirmed)
}

Builder Pattern

final class SessionBuilder {
    private var id: UUID = UUID()
    private var status: SessionStatus = .idle
    private var startDate: Date = Date()
    private var duration: TimeInterval = 3600
    
    func withId(_ id: UUID) -> Self {
        self.id = id
        return self
    }
    
    func withStatus(_ status: SessionStatus) -> Self {
        self.status = status
        return self
    }
    
    func withStartDate(_ date: Date) -> Self {
        self.startDate = date
        return self
    }
    
    func withDuration(_ duration: TimeInterval) -> Self {
        self.duration = duration
        return self
    }
    
    func build() -> Session {
        Session(
            id: id,
            status: status,
            startDate: startDate,
            duration: duration
        )
    }
}

// Usage
let session = SessionBuilder()
    .withStatus(.active)
    .withDuration(1800)
    .build()

Test Isolation

Swift Testing (struct-based)

Each test gets fresh instances via init():

@Suite struct ViewModelTests {
    var repository: SessionRepositoryStub
    var sut: SessionViewModel
    
    init() {
        // Fresh instances for each test
        repository = SessionRepositoryStub()
        sut = SessionViewModel(repository: repository)
    }
}

MainActor Isolation

For ViewModels with @MainActor:

@Suite("SessionViewModel")
@MainActor
struct SessionViewModelTests {
    var sut: SessionViewModel
    var repository: SessionRepositoryStub
    
    init() {
        repository = SessionRepositoryStub()
        sut = SessionViewModel(repository: repository)
    }
    
    @Test func shouldUpdateState_whenLoaded() async {
        repository.getSessionResult = .success(SessionBuilder().build())
        
        await sut.load()
        
        #expect(sut.state == .loaded)
    }
}

Test DIContainer

For integration tests requiring DI:

final class TestDIContainer {
    static func configured(
        sessionRepository: SessionRepositoryProtocol = SessionRepositoryStub(),
        monitor: MonitorProtocol = MonitorStub()
    ) -> DIContainer {
        let container = DIContainer()
        container.register(SessionRepositoryProtocol.self, implementation: sessionRepository)
        container.register(MonitorProtocol.self, implementation: monitor)
        return container
    }
}

Async Testing

Basic Async

@Test func shouldFetchData_whenCalled() async {
    let result = await sut.fetchData()
    #expect(result.isSuccess)
}

Async with Throws

@Test func shouldThrow_whenInvalidInput() async throws {
    await #expect(throws: ValidationError.self) {
        try await sut.process(invalidInput)
    }
}

Testing Published Properties

@Test func shouldUpdatePublishedState() async {
    // Given
    repository.getSessionResult = .success(SessionBuilder().build())
    
    // When
    await sut.load()
    
    // Then - MainActor ensures @Published updates are visible
    #expect(sut.session != nil)
}

Confirmation (for callbacks/delegates)

@Test func shouldNotifyDelegate_whenComplete() async {
    await confirmation { confirm in
        sut.onComplete = { confirm() }
        await sut.execute()
    }
}

// With timeout
@Test func shouldComplete_withinTimeout() async {
    await confirmation(timeout: .seconds(2)) { confirm in
        sut.onComplete = { confirm() }
        await sut.start()
    }
}

XCUITest Patterns

Page Object Pattern

// Pages/HomePage.swift
struct HomePage {
    let app: XCUIApplication
    
    // MARK: - Elements
    var startButton: XCUIElement {
        app.buttons["start-session-button"]
    }
    
    var sessionStatus: XCUIElement {
        app.staticTexts["session-status-label"]
    }
    
    var settingsButton: XCUIElement {
        app.buttons["settings-button"]
    }
    
    // MARK: - Actions
    @discardableResult
    func tapStart() -> SessionPage {
        startButton.tap()
        return SessionPage(app: app)
    }
    
    func tapSettings() -> SettingsPage {
        settingsButton.tap()
        return SettingsPage(app: app)
    }
    
    // MARK: - Assertions
    func assertSessionStatus(_ expected: String) -> Self {
        XCTAssertEqual(sessionStatus.label, expected)
        return self
    }
}

Accessibility Identifiers

In production code:

Button("Start Session") {
    viewModel.startSession()
}
.accessibilityIdentifier("start-session-button")

Text(viewModel.statusText)
    .accessibilityIdentifier("session-status-label")

UI Test Structure

// {app}UITests/Components/StartButtonTests.swift
final class StartButtonTests: XCTestCase {
    var app: XCUIApplication!
    var homePage: HomePage!
    
    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--ui-testing"]
        app.launch()
        homePage = HomePage(app: app)
    }
    
    func test_startButton_shouldBeVisible_whenAppLaunches() {
        XCTAssertTrue(homePage.startButton.exists)
        XCTAssertTrue(homePage.startButton.isEnabled)
    }
    
    func test_startButton_shouldNavigateToSession_whenTapped() {
        let sessionPage = homePage.tapStart()
        XCTAssertTrue(sessionPage.timerLabel.waitForExistence(timeout: 2))
    }
}

E2E Test Structure

// {app}E2ETests/Flows/SessionFlowTests.swift
final class SessionFlowTests: XCTestCase {
    var app: XCUIApplication!
    
    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--e2e-testing", "--reset-state"]
        app.launch()
    }
    
    func test_completeSessionFlow_shouldShowSummary() {
        HomePage(app: app)
            .tapStart()
            .waitForTimerToStart()
            .tapPause()
            .tapStop()
            .assertSummaryVisible()
    }
}

Launch Arguments for Test Modes

// In App
@main
struct MyApp: App {
    init() {
        #if DEBUG
        if CommandLine.arguments.contains("--ui-testing") {
            configureForUITesting()
        }
        if CommandLine.arguments.contains("--reset-state") {
            resetAllState()
        }
        #endif
    }
}

Anti-Patterns

❌ Testing Implementation Details

// ❌ Bad: Tests internal state
@Test func shouldSetInternalFlag() async {
    await sut.load()
    #expect(sut.internalLoadingFlag == true)  // Implementation detail
}

// ✅ Good: Tests observable behavior
@Test func shouldShowLoading_whenFetching() async {
    let task = Task { await sut.load() }
    #expect(sut.isLoading == true)
    await task.value
}

❌ Over-Mocking

// ❌ Bad: Mocking everything
@Test func shouldWork() async {
    let mockA = MockA()
    let mockB = MockB()
    let mockC = MockC()
    let mockD = MockD()
    // 10 more mocks...
}

// ✅ Good: Only mock boundaries
@Test func shouldWork() async {
    let repository = SessionRepositoryStub()  // External boundary only
    let sut = GetSessionUseCase(repository: repository)
}

❌ Flaky Tests

// ❌ Bad: Race condition
@Test func shouldUpdate() async {
    sut.startAsync()
    #expect(sut.value == expected)  // May not be updated yet
}

// ✅ Good: Await completion
@Test func shouldUpdate() async {
    await sut.startAsync()
    #expect(sut.value == expected)
}

❌ Test Interdependence

// ❌ Bad: Shared mutable state
static var sharedSession: Session?

@Test func test1() { Self.sharedSession = SessionBuilder().build() }
@Test func test2() { #expect(Self.sharedSession != nil) }  // Depends on test1

// ✅ Good: Independent setup
@Test func test1() {
    let session = SessionBuilder().build()
    // use session
}

❌ Magic Values

// ❌ Bad: Unexplained values
#expect(result.count == 3)

// ✅ Good: Explicit setup
let items = [item1, item2, item3]
repository.itemsResult = .success(items)
let result = await sut.getItems()
#expect(result.count == items.count)

Workflows

For step-by-step procedures, see references/workflows.md:

  • Write Tests Workflow — Identify test type → Follow type-specific steps → Verify quality
  • Refactor Test Workflow — Identify problem → Diagnose root cause → Apply fix → Verify

References

For templates and utilities, see references/test-utils.md:

  • Protocol Stub template
  • Builder template
  • TestDIContainer
  • XCUITest helpers
  • Common assertions