Flutter Widget Tests (this project)
How this project writes widget tests. Follow these when adding or changing tests under frontend/test/.
Core principle
Test behavior through the UI, not controller internals. A widget test that taps "delete" and asserts the repo no longer contains the item is worth more than a controller test asserting the same state transition — and it covers the same lines of controller code for free.
BDD naming
Every test name reads given X, when Y, then Z. Helpers mirror the structure:
testWidgets(
'given a trip with entries, when the detail screen loads, '
'then the trip name and each timeline entry are rendered',
(tester) async {
await _givenTripDetailScreen(tester, ...);
_thenTripNameIsVisible('Kyoto');
_thenAtLeastOneTimelineEntryIsShown();
},
);
Private helpers: _givenXScreen(), _whenUserTapsY(), _thenZIsVisible().
Merge tests sharing same Given/When
If two sibling tests have identical Given and When and only differ in Then, merge them into one test with multiple assertions. Three static-render tests that all skip interaction and check different parts of the initial view should be one test.
Use project helpers, not raw pumpWidget
pumpScreen(tester, child: ..., overrides: [...])— simple screen, no routingpumpRouterApp(tester, routes: [...], overrides: [...])— whenever the screen callscontext.push/pushNamed/context.pop
Both live in test/helpers/pump_app.dart and already bootstrap EasyLocalization, SharedPreferences mocks, and ProviderScope.
Fakes, not mocks
Never use mocktail for repositories or services. Use the existing fakes under test/fakes/:
- In-memory repos:
InMemoryJourneyRepository,InMemoryTripRepository,InMemoryUsageRepository,InMemoryQuickGuideRepository,InMemorySavedLocationsRepository, ... - Fake services:
FakeImagePickerService,FakeNarrationService,FakeQuickGuideAiService,FakeImageAnalysisService,FakeLocationService,FakePlacesRepository,FakeRewardedAdService
If you need to assert "was this called?" — extend the fake with a counter or captured-args field (see FakePlacesRepository.nearbyCallCount, FakeImagePickerService.pickCount). Don't reach for a mock.
Cover interactions, not just static render
Interaction paths that screen tests commonly miss:
- Dialogs and bottom sheets (default hidden, need user action to open)
- FloatingActionButton callbacks (rendered but
onPressednever fires) - Multi-select / edit mode entry + batch actions
- Error / retry / empty / loading states
- Off-screen-render widgets used by services (capture/export flows)
Checklist for a screen test: tap, onPressed, onTap, showDialog, showModalBottomSheet, error state, empty state. If none appear in the file, interaction coverage is missing.
Prefer widget test over controller/notifier test
Controller tests usually cover the same lines widget tests already hit via the UI. Delete the controller test unless it covers one of these non-UI contracts:
- SharedPreferences or other persistence
- Error-type mapping branches with no visible difference
- State machine transitions the UI can't observe
For everything else, cover the behavior with a widget test that asserts the UI outcome (or the underlying repo's state after the interaction).
Router tests
Replace destination routes with stub Scaffolds that capture state.extra — assert on what was pushed rather than navigating through real screens:
final extras = <Object?>[];
await pumpRouterApp(tester, routes: [
GoRoute(path: '/', builder: (_, __) => const MyScreen()),
GoRoute(name: 'player', path: '/player', builder: (_, state) {
extras.add(state.extra);
return const Scaffold(key: Key('player-screen'));
}),
], overrides: [...]);
// ... trigger action ...
expect(extras.single, isA<Map<String, dynamic>>());
If the test exercises context.pop(), start at / and push to the screen under test — popping from the initialLocation leaves an empty stack.
i18n inside tests
pumpScreen wires _EmptyAssetLoader, so tr() returns the raw key — assert on 'passport.title', 'camera.take_photo', etc. If the raw key causes a RenderFlex overflowed error in a fixed-width widget, write a local _MapAssetLoader that returns short Chinese/English strings (see journey_sharing_card_test.dart).
Image.memory needs valid bytes
Invalid bytes trigger a codec exception that marks the test failed even if assertions pass. Copy the 1x1 transparent PNG helper from journey_sharing_card_test.dart / select_narration_aspect_screen_test.dart when seeding imageBytes.
Common gotchas
- Chips in
SingleChildScrollView: calltester.ensureVisible(chipFinder)beforetap— they may be clipped off-screen on the default 800×600 test surface. pumpvspumpAndSettle: usepumpAndSettle()after interactions that chain async work (provider invalidation, navigation, dialog animations). Usepump(Duration)to step through controlled animations.- Static test state: if a test uses static capture fields (
_Host.lastSelection), reset them insetUp()— not just at the end of the test body. - Search-field clear suffix: on fields where the suffix appears conditionally on
controller.text.isNotEmpty, the widget only rebuilds ononChanged/ submit. You may need to submit once before the clear icon exists. - Current-trip / prefs tests:
SharedPreferences.setMockInitialValues({...})BEFOREpumpScreento seed persisted state, then don't override the notifier — test the real hydration path.
When in doubt
Look at an existing test in the same feature. The patterns above were extracted from the actual test corpus; consistency with neighbors is usually the right call.
Source: easylive1989/instant_explore — distributed by TomeVault.