This document explains how Zhip's test layer is organized, how dependency injection works in tests, and what you need to do when adding a new test file or fresh dependency.
- Test framework: XCTest (Swift Testing can be introduced incrementally — the mix is fine).
- Pattern: strict Arrange-Act-Assert. One-line arrange / one-line act / one-line assert is the goal; more than five lines in any phase is a smell.
- DI: every injectable dependency resolves from
Container.shared(Sources/AppFeature/DI/Container.swift). Tests substitute fakes withContainer.shared.<factory>.register { Mock() }andContainer.shared.manager.reset()intearDown. - Running locally:
just test(unit tests) orjust cov(tests + coverage table).just cov-detailedhighlights every uncovered line — read it when triaging low coverage areas.
Tests/
├── Configurations/ # xcconfig files
├── Extensions/
│ └── ViewModel/ # helpers that extend ZHIP types for tests
├── Helpers/ # ← new in this refactor. See "Helpers" below.
│ ├── InMemoryKeyValueStore.swift
│ ├── TestStoreFactory.swift
│ ├── InputFromControllerFactory.swift
│ └── MockOnboardingUseCase.swift
├── SupportingFiles/ # Info.plist
└── Tests/
├── AutoEnumCaseNameTests.swift
├── BalanceLastUpdatedFormatterTests.swift
├── CharacterSetTests.swift
├── StringFormattingTests.swift
├── DI/
│ └── ContainerTests.swift
├── UseCases/
│ ├── DefaultOnboardingUseCaseTests.swift
│ └── DefaultPincodeUseCaseTests.swift
└── ViewModels/
├── AskForCrashReportingPermissionsViewModelTests.swift
├── TermsOfServiceViewModelTests.swift
└── WelcomeViewModelTests.swift
The Tests/ directory on disk is not automatically synced to the
ZhipTests target. When you pull these refactor commits, Xcode won't see
the new files in Tests/Helpers/ or Tests/Tests/**/ until you add them to
the target. One-time setup:
- Open
Zhip.xcodeprojin Xcode. - Right-click the
ZhipTestsgroup → Add Files to "Zhip"…. - Select the
Tests/Helpers/andTests/Tests/DI/,Tests/Tests/UseCases/,Tests/Tests/ViewModels/directories. - In the dialog: uncheck "Copy items if needed", set Targets → only ZhipTests, click Add.
- Same story for
Sources/AppFeature/DI/Container.swift— add to the Zhip target (not ZhipTests).
You can confirm everything is wired by running just test.
Use cases with no external SDK dependencies (pincode, onboarding) are easiest:
final class DefaultPincodeUseCaseTests: XCTestCase {
private var preferences: Preferences!
private var secureStore: SecurePersistence!
private var sut: DefaultPincodeUseCase!
override func setUp() {
super.setUp()
preferences = TestStoreFactory.makePreferences()
secureStore = TestStoreFactory.makeSecurePersistence()
sut = DefaultPincodeUseCase(preferences: preferences, securePersistence: secureStore)
}
override func tearDown() {
sut = nil
secureStore = nil
preferences = nil
super.tearDown()
}
func test_hasConfiguredPincode_isTrue_afterUserChoosesPincode() throws {
// Arrange
let pin = try Pincode(digits: [.one, .two, .three, .four])
// Act
sut.userChoose(pincode: pin)
// Assert
XCTAssertTrue(sut.hasConfiguredPincode)
}
}The helpers (TestStoreFactory, FakeInputFromController, MockOnboardingUseCase)
all live under Tests/Helpers/ and use @testable import Zhip.
KeyValueStore<KeychainKey>.walletreads fromPreferences.default(UserDefaults.standard) to detect a first launch.TestStoreFactory.makeSecurePersistence()preemptively flipshasRunAppBeforetotruein UserDefaults so the getter doesn't wipe out the in-memory test state. This is a global side-effect — if your test needs to assert on the "first launch" branch, register a differentpreferencesviaContainer.shared.preferences.register { ... }.DefaultOnboardingUseCase.answeredCrashReportingQuestion(...)calls into Firebase (setupCrashReportingIfAllowed). We deliberately don't exercise that side-effect in a unit test because Firebase would need to be configured at runtime. Re-architectDefaultOnboardingUseCaseto inject aCrashReportingConfiguratordependency if you want full coverage.
ViewModels are tested by driving their InputFromView subjects and observing
navigator.navigation + output publishers:
func test_didAcceptTerms_callsUseCaseAndEmitsAccept() {
// Arrange
let useCase = MockOnboardingUseCase()
let didAcceptTerms = PassthroughSubject<Void, Never>()
let sut = TermsOfServiceViewModel(useCase: useCase, isDismissible: false)
let input = TermsOfServiceViewModel.Input(
fromView: .init(
didScrollToBottom: Empty().eraseToAnyPublisher(),
didAcceptTerms: didAcceptTerms.eraseToAnyPublisher()
),
fromController: FakeInputFromController().makeInput()
)
_ = sut.transform(input: input)
var observed: TermsOfServiceNavigation?
sut.navigator.navigation.sink { observed = $0 }.store(in: &cancellables)
// Act
didAcceptTerms.send(())
// Assert
XCTAssertEqual(useCase.didAcceptTermsOfServiceCallCount, 1)
}FakeInputFromController exposes every subject the real InputFromController
sees. Feed it with the lifecycle events your test needs; inspect the output
subjects (titleSubject, toastSubject, etc.) to assert that the ViewModel
reacted correctly.
The DI layer lives at Sources/AppFeature/DI/Container.swift. It is
intentionally shaped like hmlongco/Factory
so you can swap this in-repo implementation for the real SPM package with
~zero call-site churn:
// Production
let wallet = Container.shared.walletUseCase()
// Test-time override
Container.shared.walletUseCase.register { MyMockWalletUseCase() }
// ...test code...
Container.shared.manager.reset() // tearDown| Factory | Type | Default |
|---|---|---|
zilliqaService |
ZilliqaServiceReactive |
DefaultZilliqaService(network: .mainnet).combine |
preferences |
Preferences |
KeyValueStore(UserDefaults.standard) |
securePersistence |
SecurePersistence |
KeyValueStore(KeychainSwift()) |
walletUseCase |
WalletUseCase |
DefaultWalletUseCase wired to the services above |
transactionsUseCase |
TransactionsUseCase |
DefaultTransactionsUseCase |
onboardingUseCase |
OnboardingUseCase |
DefaultOnboardingUseCase |
pincodeUseCase |
PincodeUseCase |
DefaultPincodeUseCase |
useCaseProvider |
UseCaseProvider |
DefaultUseCaseProvider |
createWalletUseCase / restoreWalletUseCase / walletStorageUseCase / verifyEncryptionPasswordUseCase / extractKeyPairUseCase |
narrow facets | point at walletUseCase() |
balanceCacheUseCase / gasPriceUseCase / fetchBalanceUseCase / sendTransactionUseCase / transactionReceiptUseCase |
narrow facets | point at transactionsUseCase() |
When you want to swap in the real SPM package:
- Add
https://github.com/hmlongco/Factoryas a Swift Package dependency of the Zhip target. - Delete
Sources/AppFeature/DI/Container.swift. - Replace the file with a set of
Containerextensions that use Factory's@Injected/Factoryproperty wrappers. The registered types are unchanged, so call sites (Container.shared.walletUseCase()) continue to compile.
swift-snapshot-testing is wired in as a ZhipTests dependency
(see project.yml). The first reference snapshot lives at:
Tests/Tests/Snapshots/__Snapshots__/WelcomeViewSnapshotTests/test_welcomeView_iPhone17.1.png
Tests/Tests/Snapshots/WelcomeViewSnapshotTests.swift is the canonical
template — copy + adapt it for new scenes. Reference images are
device-/iOS-version-specific, so always use the same simulator that CI uses
(see justfile's sim_device / sim_os, currently iPhone 17 / iOS 26.1).
Adding a new snapshot test:
- Create
Tests/Tests/Snapshots/<Scene>SnapshotTests.swiftmodelled onWelcomeViewSnapshotTests.swift. - Run
just gento regenerate the project, then run the new test once — it fails with "No reference was found" and auto-records the PNG under__Snapshots__/<TestClass>/. - Inspect the recorded PNG visually, then commit it alongside the test file.
- Re-run the test to confirm it passes against the new baseline.
Re-recording an existing snapshot (e.g. after an intentional UI change):
delete the existing PNG and re-run the test, or wrap the test body in
withSnapshotTesting(record: .all) { … } for a one-shot record.
Snapshot coverage to target over time: every top-level view in
Sources/AppFeature/Scenes/ — Welcome (done), TermsOfService,
AskForCrashReportingPermissions, WarningCustomECC, ChooseWallet, Main,
Send, Receive, Settings, plus the pincode / backup flows.
Run just cov for a per-file table, just cov-detailed to see individual
uncovered lines. Current aspiration:
- Use cases: 95%+ (pincode + onboarding already testable; wallet +
transactions need a
ZilliqaServiceReactivefake — see below). - ViewModels: 90%+ by mocking their use cases.
- Views: validated via snapshot tests for rendering + interaction via ViewModel tests.
- Validators / formatters / extensions: 100% — they're pure functions.
Zesame.ZilliqaServiceReactive is an external protocol with many methods.
To unit-test DefaultWalletUseCase / DefaultTransactionsUseCase without
touching the network, you need a hand-rolled FakeZilliqaService that
conforms to the protocol and stubs every method — probably 20-30 lines per
method. This is the biggest remaining blocker to 95% coverage on the use
case layer. If you build one, put it in
Tests/Helpers/FakeZilliqaService.swift and register it with
Container.shared.zilliqaService.register { FakeZilliqaService() }.
- Use backticks + plain English for Swift Testing (
@Test):@Test("ensure a new wallet creates a random private key"). - Use
test_<subject>_<expectation>for XCTest:test_didAcceptTerms_callsUseCaseAndEmitsAccept. - Names that read like "subject verb expectation" beat Hungarian-style
testDidAcceptTerms1every time.