Files
claude-code-skills-reference/iOS-APP-developer/references/testing-mainactor.md
daymade 4e3a54175e Release v1.18.0: Add iOS-APP-developer and promptfoo-evaluation skills
### Added
- **New Skill**: iOS-APP-developer v1.1.0 - iOS development with XcodeGen, SwiftUI, and SPM
  - XcodeGen project.yml configuration
  - SPM dependency resolution
  - Device deployment and code signing
  - Camera/AVFoundation debugging
  - iOS version compatibility handling
  - Library not loaded @rpath framework error fixes
  - State machine testing patterns for @MainActor classes
  - Bundled references: xcodegen-full.md, camera-avfoundation.md, swiftui-compatibility.md, testing-mainactor.md

- **New Skill**: promptfoo-evaluation v1.0.0 - LLM evaluation framework using Promptfoo
  - Promptfoo configuration (promptfooconfig.yaml)
  - Python custom assertions
  - llm-rubric for LLM-as-judge evaluations
  - Few-shot example management
  - Model comparison and prompt testing
  - Bundled reference: promptfoo_api.md

### Changed
- Updated marketplace version from 1.16.0 to 1.18.0
- Updated marketplace skills count from 23 to 25
- Updated skill-creator to v1.2.2:
  - Fixed best practices documentation URL (platform.claude.com)
  - Enhanced quick_validate.py to exclude file:// prefixed paths from validation
- Updated marketplace.json metadata description to include new skills

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 17:23:08 +08:00

3.8 KiB

Testing @MainActor Classes

The Problem

Testing @MainActor classes like ObservableObject controllers in Swift 6 is challenging because:

  1. setUp() and tearDown() are nonisolated
  2. Properties with private(set) can't be set from tests
  3. Direct property access from tests triggers concurrency errors

Solution: setStateForTesting Pattern

Add a DEBUG-only method to allow tests to set internal state:

@MainActor
final class TrainingSessionController: ObservableObject {
    @Published private(set) var state: TrainingState = .idle

    // ... rest of controller ...

    // MARK: - Testing Support

    #if DEBUG
    /// Set state directly for testing purposes only
    func setStateForTesting(_ newState: TrainingState) {
        state = newState
    }
    #endif
}

Test Class Structure

import XCTest
@testable import YourApp

@MainActor
final class TrainingSessionControllerTests: XCTestCase {

    var controller: TrainingSessionController!

    override func setUp() {
        super.setUp()
        controller = TrainingSessionController()
    }

    override func tearDown() {
        controller = nil
        super.tearDown()
    }

    func testConfigureFromFailedStateAutoResets() {
        // Arrange: Set to failed state
        controller.setStateForTesting(.failed("Recording too short"))

        // Act: Configure should recover
        controller.configure(with: PhaseConfig.default)

        // Assert: Should be back in idle
        XCTAssertEqual(controller.state, .idle)
    }
}

State Machine Testing Patterns

Testing State Transitions

func testStateTransitions() {
    // Test each state's behavior
    let states: [TrainingState] = [.idle, .completed, .failed("error")]

    for state in states {
        controller.setStateForTesting(state)
        controller.configure(with: PhaseConfig.default)

        // Verify expected outcome
        XCTAssertTrue(controller.canStart, "\(state) should allow starting")
    }
}

Regression Tests

For bugs that have been fixed, add specific regression tests:

/// Regression test for: State machine dead-lock after recording failure
/// Bug: After error, controller stayed in failed state forever
func testRegressionFailedStateDeadLock() {
    // Simulate the bug scenario
    controller.configure(with: PhaseConfig.default)
    controller.setStateForTesting(.failed("录音太短"))

    // The fix: configure() should auto-reset from failed state
    controller.configure(with: PhaseConfig.default)

    XCTAssertEqual(controller.state, .idle,
        "REGRESSION: Failed state should not block configure()")
}

State Machine Invariants

Test invariants that should always hold:

/// No terminal state should become a "dead end"
func testAllTerminalStatesAreRecoverable() {
    let terminalStates: [TrainingState] = [
        .idle,
        .completed,
        .failed("test error")
    ]

    for state in terminalStates {
        controller.setStateForTesting(state)
        // Action that should recover
        controller.configure(with: PhaseConfig.default)

        // Verify recovery
        XCTAssertTrue(canConfigure(),
            "\(state) should be recoverable via configure()")
    }
}

Why This Pattern Works

  1. #if DEBUG: Method only exists in test builds, zero production overhead
  2. Explicit method: Makes test-only state manipulation obvious and searchable
  3. MainActor compatible: Method is part of the @MainActor class
  4. Swift 6 safe: Avoids concurrency errors by staying on the main actor

Alternative: Internal Setter

If you prefer, you can use internal(set) instead of private(set):

@Published internal(set) var state: TrainingState = .idle

However, this is redundant since properties are already internal by default. The setStateForTesting() pattern is more explicit about test-only intent.