Skip to main content
A Passing Test Suite Can Hide a Feature Nobody Wired Up
mobileflutterbiometrics+4

A Passing Test Suite Can Hide a Feature Nobody Wired Up

A fully-tested app lock in our finance app read as shipped because its logic was green — but nothing called it. Here's why coverage lies about integration, plus five platform traps that only fail on the right hardware.

A green suite around code nothing calls looks exactly like a shipped feature

Here’s the trap, and it’s a nasty one because it’s silent: you can have a module with full unit-test coverage, every case passing, and the feature still does nothing — because no caller ever invokes it. A test suite proves the logic is correct. It says nothing about whether anything reaches that logic. From the outside, a tested-but-unwired function and a real feature are indistinguishable until someone reads the call graph.

We hit exactly this in the app lock for our finance app. The lock was a pure function — take a preference, decide whether to gate the screen — and it was tested thoroughly. It was also, as our own CLAUDE.md note put it, “NOT WIRED, and it reads as shipped because its logic is tested.” That second clause is the whole problem. The green checkmark is precisely what makes an unwired feature look done. Coverage answers “is this code correct,” never “does this code run.”

So we wired it up. And the act of wiring a biometric lock into a real Flutter app on real phones surfaced five platform-level traps, each of which passes every manual test right up until it doesn’t.

Five ways a biometric lock passes every test and still fails

1. A per-device fact stored as a per-account setting. Whether biometrics exist is a property of the device, not the user. We’d naturally reach for the synced user_settings table — but that’s wrong. Enable the lock on a phone with Face ID, and the synced preference silently force-locks a tablet that has no sensor at all, leaving the user staring at an unlock prompt they can never satisfy. The lock preference has to live in device-local secure storage.

2. instanceof checks that compile fine and crash at one call site. Android’s LocalAuthPlugin does a literal instanceof FragmentActivity check on its host. A plain FlutterActivity compiles, installs, and runs perfectly — the app launches, everything works — and then throws the first time someone taps Unlock. Nothing in the build or install flags it. Third-party plugins that type-check their host object will pass everything except the one code path that exercises the check.

3. Face ID and Touch ID don’t fail the same way. On iOS, the auth call silently terminates the app if NSFaceIDUsageDescription is missing from the Info plist. The catch: only Face ID consults that key. A Touch ID device passes every manual test you can throw at it, while an iPhone 16 Pro crashes on first unlock. If you test on the device on your desk instead of across the Face-ID-vs-Touch-ID split, you’ll ship the crash.

4. Where you layer the lock gate decides whether navigation survives. We layered the lock over the router’s child inside MaterialApp.router’s builder, rather than replacing the child. That matters: go_router keeps its navigation state through an unlock, so users land back where they were instead of getting bounced to the root.

5. The privacy cover triggers on inactive, not paused. The blur that hides your financial data in the app-switcher has to fire on the inactive lifecycle state, because that’s the moment iOS uses to snapshot the app-switcher thumbnail. Wait for paused and iOS has already captured your account balances into the thumbnail.

None of these show up on an emulator or on “the device you happen to have.” They need the actual hardware variants.

The same discipline, applied to the backup

The other half of that commit was a Postgres backup command — and it carries the same lesson in reverse. A backup that has never been restored is the database equivalent of tested-but-unwired code: it looks like safety, but nobody has confirmed it works end to end. So this backup rehearses its own restore: it dumps, restores into a scratch database, diff-checks table and row counts, then drops the scratch DB. A backup you’ve never restored isn’t a backup; it’s a hopeful file.

We also documented two environment variables that, left at their empty defaults, make email ingestion fail with a quiet 503 instead of a loud error. That’s the same trap once more — a silent no-op that looks like health.

What to actually check

  • Read the call graph, not just the coverage report. Before you trust a green suite, confirm something in production actually invokes the tested code.
  • Keep per-device facts out of synced per-account tables. Biometric availability, sensor type, and similar hardware truths should live in device-local storage.
  • Test across the hardware split that changes behaviour — Face ID vs Touch ID, phone vs tablet — not just the device on your desk.
  • Restore your backups automatically. If the restore isn’t rehearsed, you don’t know you have one.

The common thread: correct code and safe data both have a lookalike that passes every check and does nothing. The only defence is to test the thing that actually runs.

Have a project in mind?

Fixed price after a paid discovery — no hourly billing. A real engineer reads every enquiry, and we reply within 24 hours.