How a stuck push gate got unblocked — and the Flutter widget-test techniques that made the hard 267 lines of chart, calendar and live-workout UI testable.
The repo contains a Flutter phone app, workout_app, that tracks strength workouts and syncs them to a PC. To keep quality high, the project installs a pre-push gate: whenever a commit touches Dart code, pushing runs two checks locally before the push is allowed to leave your machine.
lib/ line, and every file must appear in the reportThat second check is strict in two ways. First, the bar is 100% — not 90%, not "the lines you changed". Second, a helper script (check_flutter_coverage.sh) also fails if any lib/**/*.dart file is missing from the coverage report, so you can't quietly earn 100% by never importing a file into a test.
The catch: this gate had never once passed for a Dart commit. It was added while the app was only ~83% covered, so ten commits — an entire phone-to-PC sync feature — sat finished but unpushable, stuck behind 316 uncovered lines. This change closes that gap and ships all of it.
| File | Before | After | Nature of the gap |
|---|---|---|---|
| storage_service.dart | 139/165 | 163/163 | migrations, restore, edge branches |
| home_screen.dart | 117/121 | 121/121 | post-navigation reloads |
| settings_screen.dart | 243/262 | 262/262 | device-flow branches, steppers |
| history_screen.dart | 167/308 | 307/307 | whole exercise drill-down + chart |
| workout_screen.dart | 144/270 | 269/269 | live-workout breaks & finish |
The two screens at the bottom — the history charts/calendar and the live-workout timer UI — held 267 of the 316 lines and were the genuinely hard part. Testing them ran head-first into a subtlety about how time works inside a Flutter widget test. That's the idea worth understanding, so it gets its own section.
A normal testWidgets test does not run on real time. Flutter wraps it in a fake clock (FakeAsync): timers only advance when you explicitly say tester.pump(someDuration). This is usually wonderful — you can "wait" 3 minutes for a rest timer in a microsecond, deterministically.
But the app's real database, sqflite, schedules work on real timers and background isolates. Under the fake clock, those never get a chance to finish. So the moment a tapped widget kicks off a database write, you get a landmine:
So the rule that unlocked the live-workout screen is: any tap that triggers a database write or starts a Timer.periodic must be dispatched inside tester.runAsync(...), which runs that code on the genuine event loop where the work can actually finish. The assertion afterward happens on the fake clock again, so you still get deterministic expects.
Two corollaries fell out of this while writing the tests:
_confirmFinish in the same zone the first tap fired in, and that continuation does six sqflite writes. So the whole finish flow — open dialog, confirm, save — has to live in a single runAsync block, not just the confirm tap.tester.longPress fights runAsync. It does its own internal fake-clock pumps to time the press, which don't advance inside runAsync. The fix is a manual gesture: startGesture, hold for a real 700 ms, then release — a real-time long-press.Pick where the tapped code runs and what it does, then dispatch the tap. Watch whether the background work completes or leaks a pending timer.
Zone discipline makes taps survive, but many lines were uncovered simply because the widget was never built. Flutter's ListView lazily builds only what's near the viewport, so the history screen's session tiles and the "DUMBBELL LUNGE" section label — which sit below an 800×600 test fold — never existed in the tree, and their lines stayed cold. The fix is to scrollUntilVisible / ensureVisible them first: scrolling builds them, which both renders and covers them.
coverage:ignore is forA few lines genuinely can't run on a Linux CI host, so they're marked coverage:ignore with a stated reason rather than faked. The bar for an ignore is "no reachable test path exists," not "this was annoying to test":
Vibration.vibrate() — hasVibrator() returns false on Linux (no Platform.isAndroid/isIOS), so the body never runs there.getDatabasesPath()) — tests always inject an in-memory or temp-file path instead.null-state branch that seeding makes impossible, and a history _load branch only reachable via a path that never calls _load.The diff is 792 insertions across ten files, but it splits cleanly into two buckets: a lot of test code, and a handful of tiny production edits that make code reachable from tests. Grouped by concern:
To test the "server not started" placeholder, a private widget had to become public — private widgets can't be constructed from a test file:
-class _ServerAddressTile extends StatelessWidget {
- const _ServerAddressTile({required this.addresses});
+/// ...Public so the empty-address fallback can be exercised in a widget test.
+class ServerAddressTile extends StatelessWidget {
+ const ServerAddressTile({required this.addresses, super.key});
And to test the schema migration, resetForTesting gained an optional path so a test can hand it a real file DB (in-memory DBs can't be reopened at a new version, which is exactly what an onUpgrade migration needs):
- static void resetForTesting() {
- _testDbPath = ':memory:';
+ static void resetForTesting({String dbPath = ':memory:'}) {
+ _testDbPath = dbPath;
Four small blocks are marked unreachable-on-CI, each with a comment explaining why — for example the haptics call:
if (await Vibration.hasVibrator()) {
+ // Android/iOS-only: hasVibrator() returns false on the Linux test host
+ // (no Platform.isAndroid/isIOS), so this body never runs there.
+ // coverage:ignore-start
unawaited(Vibration.vibrate(duration: 800));
+ // coverage:ignore-end
}
The bulk of the change. A representative live-workout test: a tap starts a rest break, and the tap is dispatched through a helper that keeps it on the real event loop so the sqflite write and periodic timer complete cleanly.
Future<void> _tapReal(WidgetTester tester, Finder f) async {
await tester.runAsync(() async {
await tester.tap(f);
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pump();
}
testWidgets('tapping a set starts a break which Skip cancels', (tester) async {
await _pump(tester, _wrap());
await _tapReal(tester, find.byType(RepCircle).first);
expect(find.byType(BreakBanner), findsOneWidget); // break started
await _tapReal(tester, find.text('Skip'));
expect(find.byType(BreakBanner), findsNothing); // Skip cancelled it
});
And the schema-migration unit test that the new dbPath seam unlocked — build a v1 database on disk, open it through StorageService (which runs at v3), and assert the migration ran:
final oldDb = await databaseFactory.openDatabase(dbFile,
options: OpenDatabaseOptions(version: 1, onCreate: /* v1 schema */));
await oldDb.close();
StorageService.resetForTesting(dbPath: dbFile); // real file, not :memory:
await StorageService.init(); // triggers onUpgrade 1 → 3
expect(await _svc.getNextWorkoutType(), 'A'); // settings table now exists
expect((await _svc.getExerciseState(name))!.successThreshold, 3); // column added
The gate also requires a spotless flutter analyze, and it exits non-zero on even an info. The last commit documents the newly-public field, reshapes the ignore comment so it isn't an over-long line, and reflows one pre-existing 81-char comment. Small, but the push doesn't go through without it.
Five questions on the reasoning behind the change. Click an answer to reveal whether it's right and why.