Why use behavioural tests in our code?
@Test func rejectsWeightEntriesDatedInTheFuture() async {
let now = Date(timeIntervalSince1970: 1_000)
let appModel = TestAppModelFactory.make(currentDate: { now })
let draft = WeightEntryDraft(
date: now.addingTimeInterval(1),
value: "75"
)
await #expect(throws: WeightEntryManager.EntryDateError.futureDate) {
try await appModel.weightEntries.save(draft, editing: nil)
}
#expect(appModel.weightEntries.entries.isEmpty)
}This test comes from Trend, the weight-tracking app. It fixes “now”, attempts to save a measurement one second in the future, expects the specific domain error, and checks that no entry was added. It describes a promise to the person recording their weight, not the internal spelling of a helper function.
The important outcome is that an invalid measurement cannot become saved history. A test that merely checks for a class named AppModel or a folder named Features would tell us nothing about that promise.
1. The approach
private func validateEntryDate(_ date: Date) throws {
guard date <= currentDate() else { throw EntryDateError.futureDate }
}This is the rule inside Trend’s weight-entry feature. The date must not be later than the injected current date. Production supplies a moving clock; the opening test supplies a fixed one.
Using the same clock boundary means the test does not have to run at a particular time or race a real second ticking over. It chooses the relevant condition directly. For calendar rules, control the calendar and time zone too; a fixed timestamp alone does not define what “today” means in every locale.
CFA’s documented rationale: inject volatile dependencies so the real feature can be tested deterministically. Keep the business rule in that feature. Do not repeat it inside a test-only implementation and then congratulate the duplicate for agreeing with itself.
2. The problem it solves
func save(_ draft: WeightEntryDraft, editing entry: WeightEntry?) async throws {
try validateEntryDate(draft.date)
if let entry {
try await weightLog.update(entry, with: draft, unit: settings.unit)
} else {
try await weightLog.add(draft, unit: settings.unit)
}
await refreshDerivedFeatures()
}The save operation calls validation before it chooses add or update. Successful persistence is followed by refreshing the derived features. The opening test calls this public operation, so it checks that validation is actually connected to the path a caller uses.
Testing the private comparison alone could miss a regression where save no longer calls it. Testing only the screen’s disabled button would miss another caller that invokes the feature directly. The domain promise belongs at the feature boundary, independent of how a screen reaches it.
The test also checks the absence of an entry. An expected error is not enough if the operation already changed data before throwing. Outcomes include both what happened and what must not have happened.
3. Why it works
@Test func repositoryFailureIsPresentedAndDoesNotDismiss() async {
let repository = InMemoryWeightRepository(saveError: .saveFailed)
let appModel = TestAppModelFactory.make(repository: repository)
let viewModel = EntryEditorViewModel(editor: appModel.weightEntries)
viewModel.draft.value = "75"
let didSave = await viewModel.save()
#expect(!didSave)
#expect(viewModel.errorMessage == "The test repository could not save.")
}This ViewModel test controls a different boundary: persistence fails. The real feature and ViewModel remain in the call path. The in-memory repository throws a named test error, and the assertions check the screen-facing result and message.
Its scope is deliberately narrower than a database test. It proves how these objects handle the injected failure, not whether SQLite can open an existing user store or whether the visible alert fits on an iPhone. Use the evidence at the boundary where it was produced.
@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)
}This successful-path test checks the other side of the same architecture: known measurements become the expected progress snapshot, goal and unit at the ViewModel. There is no screenshot comparison or artificial network wait. Swift Testing’s expectations make the observed values explicit when an assertion fails.
4. Advantages of using it
@Test func settingsCommandsRemainBehindAppModelBoundary() async {
let repository = InMemoryWeightRepository(cloudStatus: .available)
let appModel = TestAppModelFactory.make(repository: repository)
appModel.settingsFeature.setWeightUnit(.pounds)
await appModel.settingsFeature.refreshCloudStatus()
#expect(appModel.settingsFeature.selectedWeightUnit == .pounds)
#expect(appModel.settingsFeature.cloudSyncStatus == .available)
}Here an isolated AppModel lets the test exercise settings without creating a Settings View. The feature boundary is useful because it exposes real operations with observable outcomes, not because the test can inspect an architecture diagram.
Editorial trade-off: isolated factories and controlled dependencies take work to maintain. Keep them small and honest about side effects; a factory that opens real production preferences or a live store is not isolated merely because its name begins with Test. Existing helper code should be reviewed alongside the test.
For every important operation, ask what a caller can observe on success, failure, overlap and cancellation. Choose controlled time or dependencies to reach those cases. Keep UI, persistence and device-performance checks as separate evidence rather than inflating the claims of a fast unit suite.
Try this with the CFA AI Toolkit for iOS:
Using CFA for iOS, test this feature’s important behaviour with deterministic 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.