Skip to content

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.

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.

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 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):

Terminal window
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/ holds four files that never appear in product navigation and are excluded from the coverage gate.

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.

FAKE_GPS=true makes the foreground-service isolate run SimulatedLocationSource instead of the real GPS. Follow the flag:

  1. AppConfig.fakeGps is !kReleaseMode && _fakeGps (app/lib/core/config/app_config.dart:29). A release build can never simulate.
  2. RaceNotifier passes it to the tracker when the race starts (app/lib/features/race/race_notifier.dart:311); the diagnostics screen passes it from LocationNotifier.startTracking (app/lib/tracking/location_provider.dart:46-47).
  3. startBackgroundTracking saves it with FlutterForegroundTask.saveData before 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.
  4. In the service, onStart reads 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).

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.

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.

Drive the simulator on the emulator. Start the backend and an emulator, then:

Terminal window
cd ../footrace && PORT=4001 mix phx.server # one terminal
cd footrace-flutter
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:4001 \
--dart-define=DEV_TOOLS=true --dart-define=FAKE_GPS=true

Sign 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.

Terminal window
make integration # resets the dev database
scripts/integration.sh --no-reset # if something else is using it

Watch 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.

  1. Why does the integration test call FlutterForegroundTask.initCommunicationPort() in setUpAll?
  2. How does the script get past the location permission dialog, given that a widget test cannot tap it?
  3. A release build is made with --dart-define=FAKE_GPS=true by mistake. What happens?
  4. 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?
  5. Why does the integration test use its own settle instead of pumpAndSettle?
Answers
  1. The test file is the process’s main(), and the app’s main() in lib/main.dart never runs. Without the port, batches from the service isolate never reach the UI.
  2. It installs the APK with adb install -g, which grants every runtime permission in the manifest, then verifies each with dumpsys package and grants stragglers with pm grant. The app finds the permission already granted and never shows a dialog.
  3. Nothing: AppConfig.fakeGps is !kReleaseMode && _fakeGps, and kReleaseMode is a compile-time true in release, so the real GPS is used.
  4. SimGpsController.sprint calls sendSimCommand, which sends {'type': 'sim', 'cmd': 'sprint', 'value': 10} with sendDataToTask; the service’s onReceiveData passes it to applyCommand. The next batch carries a sim snapshot with sprinting: true, which the controller turns into state and the overlay displays.
  5. The race screen animates continuously, so there is always another frame scheduled and pumpAndSettle would time out. settle pumps for a fixed time instead.

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/ and lib/features/race/race_notifier.dart open; it is where the race logic is least obvious.
  • lib/tracking/location_service.dart end to end. The file CLAUDE.md calls 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.md up 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.dart for a path you are unsure of. Writing one test against testOverrides will 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.