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
- Every ViewModel has its own test file and suite; never one shared suite for several screens.
- Every concrete Feature has a named test file under FeatureTests.
- Await async functions directly; do not use sleeps.
- Give cancellation and race-prevention behaviour dedicated tests.
- Test that repeated equivalent calls share one execution and that queued commands run in admission order.
- Tests that need AppModel assemble an isolated graph and never alter the shared instance.
- 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.
