Summary
Refactor AppState to support dependency injection for testability and CI compatibility. Currently, AppState creates real service instances that require macOS hardware (microphone, accessibility APIs), causing crashes in headless CI environments.
Problem
AppState directly instantiates concrete services in init()
- Tests that create
AppState crash on CI runners without macOS hardware
- Current workaround:
XCTSkipIf(Self.isCI) skips tests entirely in CI
- This reduces test coverage and masks potential regressions
Proposed Solution
1. Refactor AppState.swift for Injection
Allow services to be injected via initializer parameters with sensible defaults:
// Sources/SpeechToTextApp/AppState.swift
import Foundation
import Observation
/// Observable app-wide state management
@Observable @MainActor
class AppState {
var settings: UserSettings
var statistics: AggregatedStats
var currentSession: RecordingSession?
var isRecording: Bool = false
var showOnboarding: Bool = false
var showSettings: Bool = false
var errorMessage: String?
// Services - injectable for testing
let fluidAudioService: FluidAudioService
let permissionService: PermissionService
let settingsService: SettingsService
let statisticsService: StatisticsService
@ObservationIgnored private var loadingTask: Task<Void, Never>?
@ObservationIgnored private nonisolated(unsafe) var deinitLoadingTask: Task<Void, Never>?
init(
fluidAudioService: FluidAudioService = FluidAudioService(),
permissionService: PermissionService = PermissionService(),
settingsService: SettingsService = SettingsService(),
statisticsService: StatisticsService = StatisticsService()
) {
self.fluidAudioService = fluidAudioService
self.permissionService = permissionService
self.settingsService = settingsService
self.statisticsService = statisticsService
self.settings = settingsService.load()
self.statistics = .empty
self.showOnboarding = !settings.onboarding.completed
let statsService = statisticsService
let task = Task {
let stats = await statsService.getAggregatedStats()
await MainActor.run { [weak self] in
self?.statistics = stats
}
}
loadingTask = task
deinitLoadingTask = task
}
deinit {
deinitLoadingTask?.cancel()
}
// ... rest of methods unchanged
}
2. Create Protocol-Based Mocks
Define protocols for each service to enable proper mocking:
// Services/Protocols/FluidAudioServiceProtocol.swift
protocol FluidAudioServiceProtocol: Actor {
func initialize(language: String) async throws
func transcribe(audioData: Data) async throws -> TranscriptionResult
var isInitialized: Bool { get async }
}
// Tests/Mocks/MockFluidAudioService.swift
actor MockFluidAudioService: FluidAudioServiceProtocol {
var initializeCalled = false
var transcribeCalled = false
var stubbedResult = TranscriptionResult(text: "Test", confidence: 1.0)
func initialize(language: String) async throws {
initializeCalled = true
}
func transcribe(audioData: Data) async throws -> TranscriptionResult {
transcribeCalled = true
return stubbedResult
}
var isInitialized: Bool { initializeCalled }
}
3. Update AppStateTests
Use mock services in tests:
@MainActor
final class AppStateTests: XCTestCase {
var appState: AppState!
var mockFluidAudio: MockFluidAudioService!
var mockPermission: MockPermissionService!
var mockSettings: MockSettingsService!
var mockStatistics: MockStatisticsService!
override func setUp() async throws {
try await super.setUp()
mockFluidAudio = MockFluidAudioService()
mockPermission = MockPermissionService()
mockSettings = MockSettingsService()
mockStatistics = MockStatisticsService()
appState = AppState(
fluidAudioService: mockFluidAudio,
permissionService: mockPermission,
settingsService: mockSettings,
statisticsService: mockStatistics
)
}
func test_initializeFluidAudio_callsService() async {
await appState.initializeFluidAudio()
let called = await mockFluidAudio.initializeCalled
XCTAssertTrue(called)
}
}
Benefits
- CI Compatibility: Tests run without macOS hardware
- Faster Tests: Mocks execute instantly vs real services
- Better Coverage: Can test error paths and edge cases
- Isolation: Tests don't affect system state
- Deterministic: No flaky tests from hardware timing
Implementation Tasks
Files to Modify
Sources/SpeechToTextApp/AppState.swift
Sources/Services/FluidAudioService.swift (add protocol conformance)
Sources/Services/PermissionService.swift (add protocol conformance)
Sources/Services/SettingsService.swift (add protocol conformance)
Sources/Services/StatisticsService.swift (add protocol conformance)
Tests/SpeechToTextTests/App/AppStateTests.swift
Tests/SpeechToTextTests/Mocks/ (new directory with mock implementations)
Related
- Current workaround:
XCTSkipIf in AppStateTests.swift (commit 453e6a1)
- CI crashes: Signal 10/11 (SIGBUS/SIGSEGV) when accessing audio/accessibility APIs
Labels
enhancement, testing, architecture
Summary
Refactor
AppStateto support dependency injection for testability and CI compatibility. Currently,AppStatecreates real service instances that require macOS hardware (microphone, accessibility APIs), causing crashes in headless CI environments.Problem
AppStatedirectly instantiates concrete services ininit()AppStatecrash on CI runners without macOS hardwareXCTSkipIf(Self.isCI)skips tests entirely in CIProposed Solution
1. Refactor AppState.swift for Injection
Allow services to be injected via initializer parameters with sensible defaults:
2. Create Protocol-Based Mocks
Define protocols for each service to enable proper mocking:
3. Update AppStateTests
Use mock services in tests:
Benefits
Implementation Tasks
AppStateinit to accept injected servicesAppStateTeststo use mocksXCTSkipIf(Self.isCI)workaroundFiles to Modify
Sources/SpeechToTextApp/AppState.swiftSources/Services/FluidAudioService.swift(add protocol conformance)Sources/Services/PermissionService.swift(add protocol conformance)Sources/Services/SettingsService.swift(add protocol conformance)Sources/Services/StatisticsService.swift(add protocol conformance)Tests/SpeechToTextTests/App/AppStateTests.swiftTests/SpeechToTextTests/Mocks/(new directory with mock implementations)Related
XCTSkipIfinAppStateTests.swift(commit 453e6a1)Labels
enhancement, testing, architecture