How do you test an app whose input is walking the dog?
Heckin Quest is a Flutter app that rewards you for walking your dog. You press Start and walk. The app records distance and moving time, rolls for an encounter every 30 seconds of movement, and sends the finished walk to a server that awards points. The main input is a person outdoors with a phone in their pocket.
That input does not fit in CI. On 2026-09-01, the night the walk
tracking landed, it did not fit anywhere: the project plan noted there
was no iOS toolchain on the dev box "and no Android device either". The
acceptance tests written for those milestones were "a real 20-minute
walk" and a "30-minute walk with the screen off", and nobody could run
either one. The CI jobs for the app, added the next day, run
flutter analyze and flutter test.
Three fakes, all above the platform
The code was not untested. It had three deliberate seams, each placed to keep tests fast and away from the phone:
- The tracker (
bd2c701) takes a plain stream of points. Its header says it is "deliberately free of geolocator and flutter_map so tests feed it plain points", and nine unit tests pin its rules. - The tracking service (
ed6bddb) sits behind an interface, "so widget tests inject a fake and never touch platform channels". - The notifier (
c014cef) is "Injectable so tests pass a no-op and never touch platform channels."
These are good seams. Each one cuts the app at a line where the logic above it can be checked without a device. But look at what the service fake's start method does:
@override
Future<bool> start() async => startResult;
The real start() asks the flutter_background_service plugin to
launch an Android foreground service, which posts a persistent
notification and keeps the GPS stream alive with the screen off. The
fake replaces all of that with a boolean. One widget test sets
startResult = false and checks that the walk screen falls back to
foreground-only tracking. That fallback is real, and it is tested. It
handles a false.
A seam below the operating system
The rig landed the next afternoon, in d9af8a5 at 14:59 on 2026-09-02.
It is an Android emulator, one GPX file, and a 116-line Python script
that connects to the emulator's console and sends the route one point
at a time:
sock.sendall(f"geo fix {lon:.7f} {lat:.7f} 20 12\n".encode())
geo fix sets the emulator's virtual GPS hardware. That puts the fake
below Android's location services, not above the app's code, and the
script's docstring says what that buys: "The app's geolocator cannot
tell the difference". Everything from the virtual GPS up is the code
that ships: the permission dialogs, the location plugin, the foreground
service and its notification, the tracker, the encounter rolls against a
dev server, and the call that finalizes the walk.
The commit message records the rig's first catch, "within the hour". The previous night's commit had configured the foreground service with a notification channel of its own:
notificationChannelId: 'walk_tracking',
Nothing created that channel. The plugin creates a channel only when you don't name one. Name one, and creating it is your job. Android will not start a foreground service whose notification points at a channel that doesn't exist, so the app crashed the moment a walk started. The fix creates the channel when notifications initialize, before any walk can begin. It landed 17 hours and 41 minutes after the bug was committed.
Every test fake had been built to skip exactly this code. The service
fake replaced the start() that crashed. The notifier fake replaced the
init where the fix went. And the tested fallback could not have helped,
because the real failure was never start() returning false. It was
the process dying.
It never reached a user. The first workflow that builds a release APK arrived a month later. But until the fix, no walk on Android could get past Start.
The walk is a circle
The rig's one fixture, route-loop.gpx, is generated, not recorded. Its
generator calls it "a real walkable loop" around Green Lake in Seattle,
but the points come from a sine and a cosine, a 180-metre circle that
follows no path anyone could walk:
sys/qa/route-loop.gpx as committed in d9af8a5, one dot per 10 seconds, in metres east and north of the first fix. Bottom: the same loop at four replay speeds, as the phone's clock sees it, against the pace check the server gained on 2026-09-29. The speeds are computed from the fixture and the server's constants, not measured on a run. Hover a dot or a line for its numbers.The replay speeds as a table
| replay | moving time on the phone | pace | finalize, since 2026-09-29 |
|---|---|---|---|
| 1x | 836 s | 1.35 m/s | accepted |
| 2x | 418 s | 2.70 m/s | accepted |
| 2.22x | 376 s | 3.00 m/s | the fastest that passes |
| 4x, the README's example | 209 s | 5.39 m/s | 422, implausible |
| 8x | 104.5 s | 10.79 m/s | 422, implausible |
Every fix is exactly two seconds after the last, and every step is the same length to within two centimetres. That is fine for what the rig set out to prove, which is that a walk can start, record and finish. But the tracker has rules for messy input, and the circle never triggers one of them. A fix less accurate than 20 metres counts time but not distance. A step under two metres is jitter. Ninety seconds without movement stops the clock. A gap of more than 60 seconds is credited only as far as the next fix can prove. A real phone in a real pocket produces all four. The fixture produces none.
The rig's docs drifted, too. They said the first encounter needs about
five minutes of moving and about 600 metres. The server's rule had
been or since the commit that wrote it, four and a half hours before
the rig, and the docs said and for 19 days before e1b6104 corrected
them. A fake comes with a second description of the system, and that
description can be wrong in its own ways.
The fake now looks like cheating
The rig's README runs the loop with --speedup 4, which divides every
delay by four, so a 14-minute walk takes three and a half. On 2026-09-29
the server gained a pace check. Finalize now rejects a walk whose moving
time implies more than 3.0 metres per second, and the comment above the
constants says why: "too fast means the distance is spoofed."
The rig is a GPS spoofer. That is the whole idea. So the bottom of the chart is the seam working as designed. The fake enters below the app, which means it meets every rule the app and the server enforce, including the one written to catch fakes. By the arithmetic, the README's own example now fails. The phone's clock sees 1,127 metres in 209 seconds, which is 5.39 metres per second, and finalize answers 422. Anything above 2.22x fails. The README still suggests 4x.
That is the price of putting the seam this low. A fake injected into the tracker could have been handed any moving time it liked. A fake at the GPS has to be plausible to everything above it.
What sits below the rig
All three fixes to the walk in October were on iPhone, and the rig is an Android emulator with its screen on.
2f6bef3, 2026-10-03. The location plugin treats a missingallowBackgroundLocationUpdatesas no, so iOS suspended the app when the phone locked. The first fix after unlock landed far from the last, and the tracker counted the distance but none of the dead minutes. The result was a walk too fast to be real, and the same pace check rejected it.d38ef4c, 2026-10-10. The background service's iOS configuration defaultsautoStartto true, so the walk service and its GPS stream ran from every app launch, and a walk's history began when the app opened rather than at Start. This is the iOS twin of the channel crash: a plugin default nobody had read, in the sameconfigureWalkService()call.a9afd4e, 2026-10-13. Two failures at once. The app read its session token from the Keychain on every request, and on iOS a Keychain read with the phone locked throws, so every 30-second encounter roll failed while the phone was in a pocket. The walk screen swallows roll errors. AndWalkNotifier.init(), the function the rig's fix had edited, passed Android settings only. The notifications plugin throws on iOS without its own settings, a catch-all marked the notifier as not ready, and encounter notifications did nothing on every iPhone.
The rig could not have shown any of these. They live in the lock screen, in iOS suspending a background app, in Keychain access rules, and in a gap between fixes. The rig's README had listed what it could not fake on its first day: "Screen-off FGS reliability on hardware, real-GPS accuracy filtering (under trees, indoors), encounter notification shapes, and a comparison walk for iOS parity". The October bugs came off that list. The last commit message is plain about where things stand: "Neither has been confirmed on a device". Its new test for the notifier goes "down to the plugin's method channel", so the unit-test seam has moved lower too.
Where to put the seam
Every fake answers one question: what does it replace? Each seam in this
app was drawn where it made tests fast, and the bugs collected just
below each line, in plugin configuration and plugin defaults, which no
fake above the plugin ever reads. The Android crash and the iOS
auto-start each came down to one line of configureWalkService().
So put the fake at the lowest layer you can drive. For an app whose input is a walk, that is the GPS, and then the code between the fake and the person is the code that ships. Make it boring on purpose, because a fake that low has to be plausible to every rule above it.
Then write down what sits below it. That list is where the next bugs come from. This rig wrote its list on day one, and October worked its way down it.