Fake the Network, Persistence, Clocks — and Permission State
You fake the network and the database in tests. But the permission dialog is the untested branch that ships broken. Network calls, disk writes, and the system clock get replaced with test doubles because they are obviously hard to control. Permission state — camera, microphone, location, notifications — is just as much an external system, yet most codebases read it straight from the OS. The result: the "granted" path gets exercised every day on your own device, and the "denied" path runs for the first time on a user's phone.
Four external systems, not three
The classic list of things to fake in tests has three entries. It should have four:
| Dependency | Why it breaks tests | What the fake returns |
|---|---|---|
| Network | Slow, flaky, needs a server | Canned responses, injected failures |
| Persistence | Leaks state between tests | In-memory store, resets per test |
| Clock | Time moves; tests become flaky | A fixed or manually advanced instant |
| Permission state | Set by the OS, one dialog per install | granted, denied, or notDetermined — your choice |
The first three are standard practice — Martin Fowler catalogued the vocabulary (dummy, fake, stub, spy, mock) two decades ago, and java.time.Clock even ships fixed() specifically "for testing, where the fixed clock ensures tests are not dependent on the current clock." Permission state deserves the same treatment, because it has the same shape: global, mutable, owned by something outside your process.
The mechanism: put a protocol in front of the dialog
Never call the OS permission API directly from feature code. Define a small interface and inject it:
protocol CameraPermission {
var status: AVAuthorizationStatus { get } // .authorized, .denied,
// .restricted, .notDetermined
func request() async -> Bool
}
struct LivePermission: CameraPermission { /* wraps AVCaptureDevice */ }
struct FakePermission: CameraPermission { // test double
var status: AVAuthorizationStatus
var grantOnRequest = false
func request() async -> Bool { grantOnRequest }
}
On Apple platforms the real states come from AVCaptureDevice.authorizationStatus(for:), which returns exactly four values: .notDetermined, .restricted, .denied, .authorized. On Android, the equivalent seam wraps ContextCompat.checkSelfPermission() and the request launcher. Either way, the fake turns each state into an ordinary constructor argument — no simulator settings, no "reset privacy" dance, no dialog to tap.
Why this matters more with AI-generated tests
A coding agent can only generate tests for branches it can reach. If permission state comes from a static OS call, the denied branch is untestable and the agent silently skips it. Put a protocol in front of it and the denied case becomes a one-line setup — FakePermission(status: .denied) — that an agent will happily enumerate alongside granted and not-yet-asked. The branch that used to ship broken finally has coverage, and it's deterministic on CI.
Power-user moves
- Test all the states, not just two. Apple has four (
restrictedis the one everyone forgets — parental controls, MDM). Android adds one-time permissions that auto-revoke. Enumerate the enum in a parameterized test. - Test the transition, not just the state.
notDetermined → request → deniedis a different code path than launching already-denied. Your fake'srequest()should let you script the answer. - Steal the pattern wholesale. Point-Free's swift-dependencies ships controllable clocks, dates, and UUIDs out of the box and lets you register your own dependencies — permission clients fit the same slot.
- Design the denied UX on purpose. Android's guidance is explicit: don't block the UI, don't nag, keep the app usable with reduced functionality. You can only verify that behavior if the denied state is one line away in a test.
Resources
- Test Double — Martin Fowler's bliki
AVCaptureDevice.authorizationStatus(for:)— Apple Developer Documentation- Request runtime permissions — Android Developers
java.time.Clock— the injectable-clock pattern, Java SE 21- swift-dependencies — Point-Free's dependency injection library
Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.