Skip to content

Refactor AppState for Dependency Injection (Protocol-Based Testing) #4

Description

@srlynch1

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

  1. CI Compatibility: Tests run without macOS hardware
  2. Faster Tests: Mocks execute instantly vs real services
  3. Better Coverage: Can test error paths and edge cases
  4. Isolation: Tests don't affect system state
  5. Deterministic: No flaky tests from hardware timing

Implementation Tasks

  • Add protocol definitions for all services
  • Update AppState init to accept injected services
  • Create mock implementations for each service protocol
  • Update AppStateTests to use mocks
  • Remove XCTSkipIf(Self.isCI) workaround
  • Verify all 319 tests pass in CI

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions