Why use concrete features in our code?
import Foundation
import Observation
@MainActor
@Observable
final class ProgressViewModel {
private let progress: ProgressManager
init(progress: ProgressManager = AppModel.shared.progressFeature) {
self.progress = progress
}
var snapshot: WeightHistoryData { progress.progressSnapshot }
var goalKilograms: Double? { progress.goalWeightKilograms }
var unit: WeightUnit { progress.selectedWeightUnit }
var isLoading: Bool { progress.isPreparingProgress }
var range: ProgressRange {
get { progress.selectedProgressRange }
set { progress.selectProgressRange(newValue) }
}
}Trend’s progress ViewModel depends directly on ProgressManager, a concrete feature owner. It does not depend on a matching ProgressFeatureAPI protocol with the same properties and functions. Calling ProgressViewModel() uses the production feature; a test can pass another instance of that concrete type.
This is the distinction CFA makes: test the real feature, and replace the external dependency that makes its behaviour difficult to control. A protocol is useful at that replacement boundary, not automatically at every arrow in a diagram.
1. The approach
import Foundation
/// Presents the progress feature as a complete capability without exposing the
/// managers that calculate it or the stores that supply its inputs.
@MainActor
final class ProgressManager {
private let progress: ProgressTracker
private let weightLog: WeightLogManager
private let settings: UserSettingsStore
init(
progress: ProgressTracker,
weightLog: WeightLogManager,
settings: UserSettingsStore
) {
self.progress = progress
self.weightLog = weightLog
self.settings = settings
}
var progressSnapshot: WeightHistoryData { progress.snapshot }
var goalWeightKilograms: Double? { weightLog.goalKilograms }
var selectedWeightUnit: WeightUnit { settings.unit }
var isPreparingProgress: Bool { progress.isLoading }
var selectedProgressRange: ProgressRange { progress.range }
func selectProgressRange(_ range: ProgressRange) {
progress.select(
range,
entries: weightLog.entries,
goalKilograms: weightLog.goalKilograms
)
}
}The feature knows how the progress tracker, weight log and settings work together. Its command and outputs are the behaviour we want a ViewModel test to exercise. Replacing the whole feature with a fake would bypass that cooperation.
Current CFA would name this class ProgressFeature; Trend’s older Manager spelling does not change the fact that it is concrete. The ViewModel’s initializer accepts that actual type. There is no second API declaration to keep in sync whenever a useful feature member changes.
CFA’s documented rationale: avoid mirroring every feature with a protocol. Use concrete feature instances in ViewModel tests, with controlled lower-level providers or storage. This is a CFA preference, not a limitation of Swift’s protocol system.
2. The problem it solves
import Foundation
protocol WeightRepository: Sendable {
func load() async throws -> WeightStore
func save(_ store: WeightStore) async throws
}
struct WeightStore: Codable, Sendable {
var entries: [WeightEntry]
var goalKilograms: Double?
}Here is a boundary where a protocol does serve a concrete purpose. Trend’s weight log needs to load and save a store. Production storage and an in-memory test repository can perform those operations differently while the real feature logic remains the same.
A fake progress feature might simply return the exact snapshot a test expects. Such a test could pass even if the real progress feature forgot to read the selected unit or supplied the wrong goal. Replacing storage instead gives the real feature known inputs and lets the test observe what it actually produces.
The older repository saves a whole WeightStore. That is the historical API used by this test, not today’s recommendation for independent record edits. Current CFA prefers record-level mutations for those operations; the substitution lesson does not require copying this older storage shape.
3. Why it works
import Foundation
@testable import Trend
actor InMemoryWeightRepository: WeightRepository, CloudSyncStatusProviding {
enum TestError: LocalizedError {
case loadFailed
case saveFailed
var errorDescription: String? {
switch self {
case .loadFailed: "The test repository could not load."
case .saveFailed: "The test repository could not save."
}
}
}
private var store: WeightStore
private let loadError: TestError?
private let saveError: TestError?
private let status: CloudSyncStatus
private(set) var loadCallCount = 0
private(set) var saveCallCount = 0
init(
store: WeightStore = .init(entries: [], goalKilograms: nil),
loadError: TestError? = nil,
saveError: TestError? = nil,
cloudStatus: CloudSyncStatus = .unavailable
) {
self.store = store
self.loadError = loadError
self.saveError = saveError
status = cloudStatus
}
func load() async throws -> WeightStore {
loadCallCount += 1
if let loadError { throw loadError }
return store
}
func save(_ store: WeightStore) async throws {
saveCallCount += 1
if let saveError { throw saveError }
self.store = store
}
func cloudStatus() async -> CloudSyncStatus { status }
}This is the complete in-memory repository used by Trend’s tests. It retains a store, counts calls and can throw a chosen load or save error. It also supplies cloud status for the settings capability. The test controls external outcomes without replacing the feature’s decisions.
Its actor isolation protects its own mutable test state. It is not a network simulator running secretly in production: this file belongs to the test target and imports the app with @testable. Fixtures should stay outside the shipping feature and provider code.
The protocol’s Sendable requirement expresses the cross-concurrency contract. It does not itself simulate storage or provide serialization of the feature’s whole workflow. Those responsibilities still belong to the repository implementation and feature respectively.
4. Advantages of using it
@Test func exposesSnapshotGoalUnitAndLoadingState() async {
let now = Date()
let entries = [
WeightEntry(date: now.addingTimeInterval(-86_400), kilograms: 82),
WeightEntry(date: now, kilograms: 80)
]
let repository = InMemoryWeightRepository(store: .init(entries: entries, goalKilograms: 75))
let appModel = TestAppModelFactory.make(repository: repository, unit: .pounds)
await appModel.weightEntries.refresh()
let viewModel = ProgressViewModel(progress: appModel.progressFeature)
#expect(viewModel.snapshot.points.map(\.kilograms) == [82, 80])
#expect(viewModel.snapshot.changeKilograms == -2)
#expect(viewModel.goalKilograms == 75)
#expect(viewModel.unit == .pounds)
#expect(!viewModel.isLoading)
}The test prepares an in-memory store with two measurements and a goal, constructs an isolated AppModel, refreshes the real feature and passes it to the real ViewModel. The assertions then check what presentation receives. This covers the connection we care about instead of the spelling of a forwarding call.
Editorial trade-off: constructing real collaborators can require more setup than returning a canned snapshot. A focused test factory keeps that setup readable. If it opens real storage or shares production preferences, improve the factory; do not disguise those effects as isolation.
Swift protocols remain valuable for genuine alternatives and capability boundaries. CFA’s rule is narrower: do not introduce a feature protocol solely because a ViewModel needs a dependency. Keep the actual feature under test and substitute the service whose outcome you need to control.
Try this with the CFA AI Toolkit for iOS:
Using CFA for iOS, test this concrete feature through controlled lower-level dependencies.
Scope: code from the linked app revisions; smaller excerpts belong to their containing types and targets. The articles distinguish older implementations from current CFA conventions. Source verification is not a full app build or device test. Suggested checks are guidance, not claims they ran against your application.