Yeda AI Tips · #092

Español

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:

DependencyWhy it breaks testsWhat the fake returns
NetworkSlow, flaky, needs a serverCanned responses, injected failures
PersistenceLeaks state between testsIn-memory store, resets per test
ClockTime moves; tests become flakyA fixed or manually advanced instant
Permission stateSet by the OS, one dialog per installgranted, 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

Resources

Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.

Talk to us · Read the blog