Day 13: Integration test, dev tools, sim GPS, release builds
Day 12 covered the tests that run on your Mac in milliseconds. Today is everything that
needs a device or a real backend: the one integration test that plays a whole race, the
shell script that sets the stage for it, the dev-only tools in lib/dev/ that make GPS
testable at a desk, and the path from make run-dev to a signed release build. This is
the last day of the series, so it ends with where to read next.
Before you start
Section titled “Before you start”From Day 9: GPS runs in a
foreground-service isolate that sends 2-second batches to the UI isolate over
FlutterForegroundTask.sendDataToMain, and the UI sends commands back with
sendDataToTask. From Day 11: RaceNotifier starts the
tracker when the gun goes, and the race screen shows a slide-to-leave control. From
Day 12: WidgetTester, find, pump, and why
pumpAndSettle never returns on an animating screen.
You also need the Android emulator set up (flutter emulators lists it) and the backend
checkout at ../footrace. The reference pages are Testing and
Build and release.
The integration test
Section titled “The integration test”Open integration_test/full_race_test.dart. It uses the same flutter_test API as the
widget tests, but the binding is different (app/integration_test/full_race_test.dart:36):
IntegrationTestWidgetsFlutterBinding.ensureInitialized();A binding is the glue between the framework and a host. Widget tests get a fake one that renders offscreen with a fake clock. This one runs inside the real app process on the device, with real time, real plugins and real platform channels. In native terms the file is an XCUITest or Espresso test, except it runs in the same process as the app and finds widgets rather than accessibility elements.
setUpAll (:40-47) does what main() does in the app: it calls
FlutterForegroundTask.initCommunicationPort(), because here the test is main() and the
service isolate cannot talk to the UI without that port. It also prints the config it was
built with, so a red run says which backend it hit. setUp(signOut) (:51) clears the
keychain and SharedPreferences before each test (:179-183), because the app process
lives across both tests.
The first test (:53-107) boots the real app with a bare ProviderScope, no overrides:
await tester.pumpWidget(const ProviderScope(child: FootraceApp()));await settle(tester);
await register(tester, name: 'QA Runner', email: email);// ...await raceTheBot(tester, 'hundred_m');
await expectVisible(tester, find.byType(RaceScreen), timeout: const Duration(seconds: 30), because: 'matchmaking should hand straight over to the race');await expectVisible(tester, find.byType(ResultScreen), timeout: const Duration(minutes: 2), because: 'the race should finish on its own');await expectVisible(tester, find.text('You won'), because: 'the simulated pace is twice the bot pace');Every network call goes to Phoenix. register fills the real form and taps
CREATE ACCOUNT. raceTheBot (:143-152) picks a distance, taps FIND OPPONENT, and then
taps “Race a bot (dev)”, a button the queue screen only shows when devTools is on
(app/lib/features/queue/queue_screen.dart:122-127). That button cancels the queue entry
and re-joins with bot: true, which the backend’s matchmaking controller turns into the
steady bot (backend/lib/footrace_web/controllers/api/matchmaking_controller.ex:129-142).
Why does the test win? The race runs on simulated GPS (more below). The comment at
:76-77 says the simulated runner moves at 3:00 /km and the bot at 6:00 /km. The code
disagrees on the first number: RaceTracker starts the service without a pace, so the
simulator uses kDefaultSimPaceSecondsPerKm = 300, 5:00 /km
(app/lib/tracking/simulated_location_source.dart:9,
app/lib/tracking/location_service.dart:320). The bot’s default is 360 s/km
(backend/lib/racing/bots.ex:26). So 100 m takes about 30 s against the bot’s 36 s. The
test still wins, by about six seconds rather than the eighteen the comment implies.
Two helpers replace the timing tools from Day 12. settle (:250-256) pumps frames for a
fixed wall-clock duration instead of waiting for quiet, because the race screen animates
forever. expectVisible (:259-272) polls a finder until a deadline and fails with the
because text:
final deadline = DateTime.now().add(timeout);while (DateTime.now().isBefore(deadline)) { if (finder.evaluate().isNotEmpty) return; await tester.pump(const Duration(milliseconds: 200));}fail('Timed out after ${timeout.inSeconds}s waiting for $finder' '${because == null ? '' : ': $because'}');This is the same idea as XCTWaiter with an expectation or Espresso idling resources,
written by hand. finder.evaluate() runs the query against the current tree and returns
the matches.
The second test (:109-136) races the bot over 5 km and leaves. Two helpers there are worth
a look because they encode things the app does on screen: moveSimGpsPanel (:206-211)
drags the simulated-GPS overlay out of the way because it sits over the slider, and
slideUntil (:215-241) drags the slide-to-confirm handle in twelve steps across frames,
retrying up to five times, because a snackbar can eat the gesture.
The script that runs it
Section titled “The script that runs it”The test cannot reset a database or boot an emulator, so scripts/integration.sh does that.
Read it top to bottom; it is plain bash.
Configuration is environment variables with defaults (app/scripts/integration.sh:14-19):
backend at ../footrace, port 4001, AVD Pixel_9a, base URL http://10.0.2.2:$PORT. A
trap cleanup EXIT (:39-56) prints the backend log tail on failure and stops the backend
if the script started it.
The backend block (:62-95) first asks GET /up (backend/lib/footrace_web/router.ex:69)
whether a server is already running. If one is, it is reused. If not, it runs
mix ecto.reset and mix footrace.seed_dev unless --no-reset was passed (:65-71),
waits for a just-killed server to release the port, starts mix phx.server in the
background, and polls /up for up to 90 seconds. Note the consequence: if your dev server
is already up on 4001, the database is not reset, whatever flags you pass.
The emulator block (:117-133) uses an attached device if there is one, waits if the AVD
is already booting, and otherwise launches it with flutter emulators --launch. Then it
waits for sys.boot_completed (:136), not just the adb connection.
The permissions block is the clever part (:146-194). A widget test cannot tap a system
permission dialog; it is not part of the Flutter tree. So the script builds the debug APK,
installs it with adb install -r -g, which grants every runtime permission in the
manifest, then checks each location and notification permission with dumpsys package
and grants any that are missing with pm grant. Missing location is fatal; missing
POST_NOTIFICATIONS is not, since it does not exist below API 33. The grants survive the
reinstall that flutter test does, because the package id stays
com.footraceapp.footrace.
Finally (:200-204):
flutter test integration_test/full_race_test.dart \ --dart-define=API_BASE_URL="$API_BASE_URL" \ --dart-define=FAKE_GPS=true \ --dart-define=DEV_TOOLS=true \ ${extra_args+"${extra_args[@]}"}flutter test on a file under integration_test/ with a device attached builds the app
with that test as its entry point, installs it and runs it on the device.
lib/dev and FAKE_GPS
Section titled “lib/dev and FAKE_GPS”lib/dev/ holds four files that never appear in product navigation and are excluded from
the coverage gate.
The GPS diagnostics screen
Section titled “The GPS diagnostics screen”GpsDiagnosticsScreen (app/lib/dev/gps_diagnostics_screen.dart:14-17) is the screen from
the original proof-of-concept branch, kept as the way to verify background tracking on a
real phone (the README’s “Verifying background tracking” checklist). It only exists as a
route when devTools is on (app/lib/app/router.dart:115-121):
// Dev tooling never appears in product navigation; the route itself// only exists when dev tools are enabled.if (config.devTools) GoRoute( path: AppRoutes.dev, builder: (_, __) => const GpsDiagnosticsScreen(), ),That if inside a list literal is Dart’s collection-if: the element is included or not,
with no null placeholder. The Profile tab has the matching entry, also behind devTools
(app/lib/features/profile/profile_tab.dart:189-199).
The screen watches locationProvider and trackingDiagnosticsProvider (:21-22) and
shows the foreground fix, the background fix, fixes per minute, the longest gap between
fixes and batches received. TrackingDiagnosticsNotifier gets those numbers by listening
to the same service messages the race uses: each {'type': 'batch', ...} map carries
totalFixes, maxGapMs and sinceLastFixMs (app/lib/dev/tracking_diagnostics.dart:78-99).
A max gap of a few seconds with the phone locked means the service survived; a large one
means the OS suspended it.
Simulated GPS
Section titled “Simulated GPS”FAKE_GPS=true makes the foreground-service isolate run SimulatedLocationSource instead
of the real GPS. Follow the flag:
AppConfig.fakeGpsis!kReleaseMode && _fakeGps(app/lib/core/config/app_config.dart:29). A release build can never simulate.RaceNotifierpasses it to the tracker when the race starts (app/lib/features/race/race_notifier.dart:311); the diagnostics screen passes it fromLocationNotifier.startTracking(app/lib/tracking/location_provider.dart:46-47).startBackgroundTrackingsaves it withFlutterForegroundTask.saveDatabefore starting the service (app/lib/tracking/location_service.dart:329-332). This is the only way to hand data to the service isolate, which cannot read Riverpod.- In the service,
onStartreads it back and picks the source (app/lib/tracking/location_service.dart:54-65).
Everything downstream is identical: the same batches, the same notification (prefixed
“SIM”), the same durable upload queue and POST /api/races/:id/positions. The backend
cannot tell the difference. Android still requires location permission to start a
location-type foreground service, so the simulator does not skip the permission flow
(app/lib/tracking/location_provider.dart:31-34).
The simulator starts at Tulln an der Donau (app/lib/tracking/simulated_location_source.dart:12)
and runs at a steady pace, one fix per second. Its controls mimic what goes wrong on a real
run: pause, sprint (double speed), lose signal (the runner keeps moving, no fixes arrive)
and bad accuracy (80 m fixes a filter should drop).
Controlling the simulator from the UI
Section titled “Controlling the simulator from the UI”The simulator lives in another isolate, so the UI cannot call it. SimGpsController
(app/lib/dev/sim_gps_controller.dart:10-34) is the UI-side handle:
void _onServiceData(Object data) { if (data is! Map || data['type'] != 'batch') return; final sim = data['sim']; if (sim is Map) state = SimSnapshot.fromMap(sim);}
void _send(String cmd, {num? value}) => ref.read(locationServiceProvider).sendSimCommand(cmd, value: value);
void pause() => _send('pause');void sprint({int seconds = 10}) => _send('sprint', value: seconds);State flows one way and commands the other. The service puts a sim snapshot into every
batch (app/lib/tracking/location_service.dart:144), and the controller turns it into
state. A tap sends {'type': 'sim', 'cmd': 'pause'} with sendDataToTask
(app/lib/tracking/location_service.dart:380-386); the service’s onReceiveData hands it
to applyCommand (app/lib/tracking/location_service.dart:206-209,
app/lib/tracking/simulated_location_source.dart:153-170). If you know the BEAM, this is a
GenServer you can only cast to, which reports its state back in its regular
broadcasts; there is no call.
SimGpsOverlay (app/lib/dev/sim_gps_overlay.dart:10-134) renders that state as a
draggable panel with a red “SIMULATED GPS” badge (:58-62) and chips for each command
(:80-109). It returns a Positioned, so it must be placed in a Stack; the race screen
mounts it at app/lib/features/race/race_screen.dart:145 and the diagnostics screen at
app/lib/dev/gps_diagnostics_screen.dart:87, both only when fakeGps is on. The drag is
a GestureDetector.onPanUpdate that moves an Offset in setState (:33-37): a
ConsumerStatefulWidget holding local UI state next to provider state, the same split as
a view controller holding a frame while observing a model.
Builds and CI in brief
Section titled “Builds and CI in brief”make run-dev is flutter run --dart-define-from-file=env/dev.json (app/Makefile:75-76),
which targets the emulator’s 10.0.2.2:4001 with dev tools on and simulation off.
make release-android BUILD_NUMBER=42 and make release-ios BUILD_NUMBER=42 build with
env/prod.json (app/Makefile:82-87). In a release build kReleaseMode is true, so
FAKE_GPS is ignored, and DEV_TOOLS is false from the file, so the dev route and the
bot button are gone. Android signing falls back to the debug key with a printed warning if
no keystore is configured (app/android/app/build.gradle.kts:63-70). The details, and how
the native shells host Flutter, are on Build and release.
CI (.github/workflows/ci.yml) runs format, analyze, tests and the coverage gate, a debug
APK build and the Widgetbook build on every push and PR. The integration job only runs
from the Actions tab (app/.github/workflows/ci.yml:104). It brings up Postgres as a
service, checks out the backend with a BACKEND_REPO_TOKEN secret, seeds it, starts
Phoenix itself and then calls scripts/integration.sh --no-reset inside an API 34
emulator (:178-192). If Phoenix has finished booting by the time the script asks
/up, the script reuses it and goes straight to the emulator. If it has not, the script
would try to start a second server on the same port; whether that race ever bites in CI
could not be verified from the repo.
Try it
Section titled “Try it”Drive the simulator on the emulator. Start the backend and an emulator, then:
cd ../footrace && PORT=4001 mix phx.server # one terminalcd footrace-flutterflutter run --dart-define=API_BASE_URL=http://10.0.2.2:4001 \ --dart-define=DEV_TOOLS=true --dart-define=FAKE_GPS=trueSign in with a seeded account (the accounts are listed in docs/use-cases/README.md), open
the Profile tab, tap GPS diagnostics, then Start race tracking and allow the
permission prompts. The SIMULATED GPS panel shows “waiting for service” until the first
2-second batch arrives, then “running”. The Foreground card stays empty: with FAKE_GPS the
foreground stream is Stream.empty() (app/lib/tracking/location_service.dart:279-280).
Watch Fixes / min settle near 60. Tap Lose signal 10 s, wait, and watch Max gap
jump to about 10 s while the panel’s distance keeps climbing. That is the scenario the
upload pipeline from Day 10 has to handle.
Run flutter logs in another terminal to see one Flush: line per batch, printed by the
service at app/lib/tracking/location_service.dart:148-149.
Optional, if you have 20 minutes: run the integration test.
make integration # resets the dev databasescripts/integration.sh --no-reset # if something else is using itWatch the emulator: registration, the bot race, the result, History, Profile. If it fails,
the script prints where its logs are. Note that make integration resets your local dev
database unless a server is already answering on 4001.
Check yourself
Section titled “Check yourself”- Why does the integration test call
FlutterForegroundTask.initCommunicationPort()insetUpAll? - How does the script get past the location permission dialog, given that a widget test cannot tap it?
- A release build is made with
--dart-define=FAKE_GPS=trueby mistake. What happens? - The simulator runs in the service isolate. How does a tap on “Sprint 10 s” reach it, and how does the overlay learn that it is sprinting?
- Why does the integration test use its own
settleinstead ofpumpAndSettle?
Answers
- The test file is the process’s
main(), and the app’smain()inlib/main.dartnever runs. Without the port, batches from the service isolate never reach the UI. - It installs the APK with
adb install -g, which grants every runtime permission in the manifest, then verifies each withdumpsys packageand grants stragglers withpm grant. The app finds the permission already granted and never shows a dialog. - Nothing:
AppConfig.fakeGpsis!kReleaseMode && _fakeGps, andkReleaseModeis a compile-timetruein release, so the real GPS is used. SimGpsController.sprintcallssendSimCommand, which sends{'type': 'sim', 'cmd': 'sprint', 'value': 10}withsendDataToTask; the service’sonReceiveDatapasses it toapplyCommand. The next batch carries asimsnapshot withsprinting: true, which the controller turns into state and the overlay displays.- The race screen animates continuously, so there is always another frame scheduled and
pumpAndSettlewould time out.settlepumps for a fixed time instead.
Where to go from here
Section titled “Where to go from here”You have now read every layer once. Some threads worth pulling next, roughly in order of how often you will need them:
- Failure modes. What the app does when the network drops, the
app is killed mid-race, or permission is revoked. Read it with
lib/tracking/upload/andlib/features/race/race_notifier.dartopen; it is where the race logic is least obvious. lib/tracking/location_service.dartend to end. The fileCLAUDE.mdcalls load-bearing. You have seen pieces of it on Days 9, 10 and today; read it top to bottom once, then re-run the README’s locked-phone check on a real device.- The contract with the backend. Pick one screen and line its fixture, its model, the
Phoenix controller and
../footrace/docs/channels.mdup side by side. The fixtures README’s list of backend quirks is a short reading list in itself. - A test you write. Add a case to
test/features/race/race_notifier_test.dartfor a path you are unsure of. Writing one test againsttestOverrideswill teach more Dart than another day of reading. - The glossary for any term that still feels borrowed, and the architecture overview to see the whole again with the detail filled in.
This is the last day. Start from the architecture overview whenever you return to the code.