CHANGE EXPLAINED · 2026-07-13

Driving the workout app to 100% test coverage

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.

1 · Background

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.

git push → flutter analyze — zero issues (even info-level)
→ flutter test --coverage — 100% of every lib/ line, and every file must appear in the report

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

Where the 316 lines lived

FileBeforeAfterNature of the gap
storage_service.dart139/165163/163migrations, restore, edge branches
home_screen.dart117/121121/121post-navigation reloads
settings_screen.dart243/262262/262device-flow branches, steppers
history_screen.dart167/308307/307whole exercise drill-down + chart
workout_screen.dart144/270269/269live-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.

2 · Intuition — two clocks, one test

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:

Fake-async zone (default)

tap outside runAsync

tap a set → _tapCircle
↓
unawaited(_saveActiveSession()) → sqflite write
↓
write parks on a real timer the fake clock never ticks
↓
✗ "Pending timers" — test fails
Real loop via tester.runAsync

tap inside runAsync

tap a set → _tapCircle
↓
unawaited(_saveActiveSession()) → sqflite write
↓
real event loop runs the write to completion
↓
✓ no leftover timers — test passes

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:

▶ Micro-world: run the test yourself

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.

Dispatch the tap in…
The tapped code…
1 · gesture dispatched, onTap fires
2 · handler runs…
3 · background work scheduled…
4 · test tears down, checks for leftover timers…
Choose options and press “Dispatch tap”.

The other half: reaching the code at all

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.

Honest 100%: what coverage:ignore is for

A 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":

Android/iOS only
Vibration.vibrate() — hasVibrator() returns false on Linux (no Platform.isAndroid/isIOS), so the body never runs there.
Production-only path
The real on-device DB path (getDatabasesPath()) — tests always inject an in-memory or temp-file path instead.
Truly unreachable
A defensive null-state branch that seeding makes impossible, and a history _load branch only reachable via a path that never calls _load.

3 · Code — what actually changed

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:

A · Test seams in production code

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;

B · Justified coverage ignores

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
    }

C · The tests — real behaviour, on the real loop

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

D · Making analyze green

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.

4 · Quiz

Five questions on the reasoning behind the change. Click an answer to reveal whether it's right and why.

Score: 0 / 0