How do you photograph three backends without rebuilding the app?
Wire the screen to a protocol. Pass live, stub, or fail at launch. Keep a hardwired Live card as the control that ignores the argument.
Protocol first
protocol BuildStatusClient: Sendable {
var sourceLabel: String { get }
func fetchLatest() async -> BuildPulseOutcome
}Three conformers. LiveBuildClient sleeps ~280 ms then returns Harbor. StubBuildClient returns the same READY instantly. FailingBuildClient returns NETWORK DOWN.
The injected card takes any BuildStatusClient from outside. That is initializer injection — the dependency arrives when the view is built, not from a singleton inside the body.
Live — both look the same
-mode live pins the client selector to LIVE.
| Panel | Source | Result |
|---|---|---|
| HARDWIRED | LIVE | READY · Harbor v1.4.0 · build 418 |
| INJECTED | LIVE | READY · Harbor v1.4.0 · build 418 |
Same numbers. Injection is invisible until you swap the client.
Stub — source changes, payload matches
-mode stub.
| Panel | Source | Result |
|---|---|---|
| HARDWIRED | LIVE | READY · build 418 |
| INJECTED | STUB | READY · build 418 |
Hardwired still says SOURCE LIVE and ignores -mode. Injected flips to SOURCE STUB with the same Harbor pulse — a deterministic stand-in for tests and screenshots.
Fail — the frame that proves DI
-mode fail.
| Panel | Source | Result |
|---|---|---|
| HARDWIRED | LIVE | READY · build 418 |
| INJECTED | FAIL | ERROR · NETWORK DOWN · no pulse |
One screen. One launch argument. The control stays healthy while the injected path shows the outage. That is substitutability photographed — not argued.
scripts/lab/shots/plans/injection.json launches -mode live|stub|fail. The bench and its screenshots share one resolution path. No second UI for “demo mode.”
Mini-exercise
Add a fourth client, SlowStubBuildClient, that sleeps two seconds then returns build 999. Pin -mode slow and confirm HARDWIRED still shows 418 while INJECTED shows 999.
Challenges
- Move client resolution into a tiny
BuildStatusFactorywith onemake(mode:)method — Design Patterns’ factory, kept local. - Unit-test
FailingBuildClientwithout launching the simulator. - Property-inject a second optional logger after init; leave the required client on the initializer.
- Replace the hardwired card with a Service Locator singleton and photograph why the control got harder to pin.
- Wire XCUITest to assert
NETWORK DOWNappears under-mode failand never under-mode live.
Key Points
- Observed LIVE: both panels SOURCE LIVE, READY Harbor 418.
- Observed STUB: HARDWIRED LIVE / INJECTED STUB, both READY 418.
- Observed FAIL: HARDWIRED READY 418; INJECTED ERROR NETWORK DOWN.
- Protocol + initializer injection enables substitutability; hardcoding Live inside the view does not.
- Capture
-modeis the same switch production tests can use. - The control is the card that refuses to accept a client from outside.
Next up: architecture patterns — the same feature as MV, MVVM, and a reducer, with body-run counts on each so the comparison is measured.
Read next
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper