### 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>
147 lines
3.8 KiB
Markdown
147 lines
3.8 KiB
Markdown
# 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:
|
|
|
|
```swift
|
|
@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
|
|
|
|
```swift
|
|
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
|
|
|
|
```swift
|
|
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:
|
|
|
|
```swift
|
|
/// 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:
|
|
|
|
```swift
|
|
/// 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)`:
|
|
|
|
```swift
|
|
@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.
|