top of page

Why use concrete features in our code?

Code from Trend • Full Xcode project ↗
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

Code from Trend • Full Xcode project ↗
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

Code from Trend • Full Xcode project ↗
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

Code from Trend • Full Xcode project ↗
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

Code from Trend • Full Xcode project ↗
@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.

bottom of page