top of page

CFA Reference: Testing

Formal CFA AI Toolkit for iOS Documentation · Reference 17 of 26

CFA tests the same boundaries the architecture advertises. The test navigator has two primary groups, View model tests and AppModel tests, and every concrete Feature has a named test file under FeatureTests.

Definitions

  • ViewModel test suite: One test file and suite per ViewModel, with an isolated Feature injected.
  • Feature test: A named API test file, such as SettingsFeatureTests.swift, that exercises business rules.
  • Isolated graph: An AppModel built with the explicit initialiser and test repositories; never AppModel.shared.
  • Deterministic double: A test implementation of a clock, repository or client with controlled results.

Rules

  1. Every ViewModel has its own test file and suite; never one shared suite for several screens.
  2. Every concrete Feature has a named test file under FeatureTests.
  3. Await async functions directly; do not use sleeps.
  4. Give cancellation and race-prevention behaviour dedicated tests.
  5. Test that repeated equivalent calls share one execution and that queued commands run in admission order.
  6. Tests that need AppModel assemble an isolated graph and never alter the shared instance.
  7. Create a unit-test target for each new app, but no UI-test target unless a UI scenario is requested.

Decision rules

  • When a Feature shares a refresh, test that overlapping calls reach storage once.
  • When a Feature validates input, test the thrown error and that state did not change.
  • When a ViewModel formats data, test the formatted output with an isolated Feature.
  • When time matters, inject a clock.

Example

These Swift Testing tests are compiled (type-checked on 6.2, built on 6.4, warnings as errors) against the Feature in Reference 5.

Compiled test (Swift Testing)

import Foundation
import Testing

/// A repository double that records how many fetches reached storage.
actor CountingHabitsRepository: HabitsRepository {
    private(set) var fetchCount = 0
    private let storedHabits: [Habit]

    init(storedHabits: [Habit]) {
        self.storedHabits = storedHabits
    }

    func fetchHabits() async throws -> [Habit] {
        fetchCount += 1
        return storedHabits
    }

    func insertHabit(_ habit: Habit) async throws {}
}

@MainActor
struct HabitsFeatureTests {
    @Test func overlappingRefreshesShareOneFetch() async throws {
        let habit = Habit(id: UUID(), name: "Read")
        let repository = CountingHabitsRepository(storedHabits: [habit])
        let habitsFeature = HabitsFeature(repository: repository)

        habitsFeature.startLoading()
        await habitsFeature.refresh()

        #expect(habitsFeature.habits == [habit])
        #expect(habitsFeature.isLoading == false)
        #expect(await repository.fetchCount == 1)
    }

    @Test func blankNameIsRejected() async {
        let habitsFeature = HabitsFeature(repository: LocalHabitsRepository())

        await #expect(throws: HabitError.invalidName) {
            try await habitsFeature.addHabit(named: "   ")
        }
        #expect(habitsFeature.habits.isEmpty)
    }
}

A test-style isolated graph, also compiled:

Compiled example (excerpt)

import Foundation
import Observation

// MARK: Test-style isolated graph (never touches AppModel.shared)
@MainActor
func makeIsolatedAppModel() -> AppModel {
    AppModel(
        habitsFeature: HabitsFeature(repository: LocalHabitsRepository()),
        settingsFeature: SettingsFeature(defaults: UserDefaults(suiteName: "tests") ?? .standard)
    )
}

@MainActor
func checkSavedHabitIsPublished() async throws -> Bool {
    let appModel = makeIsolatedAppModel()
    try await appModel.habitsFeature.addHabit(named: "  Read  ")
    return appModel.habitsFeature.habits.map(\.name) == ["Read"]
}

Common mistakes

  • Using Task.sleep to wait for work to finish.
  • Testing private implementation details instead of observable behaviour.
  • Using AppModel.shared in tests.
  • Adding UI tests to compensate for untestable Tasks hidden in ViewModels.

Links

bottom of page