-
Notifications
You must be signed in to change notification settings - Fork 0
Dev Testing TDD Pyramid
Target composition:
- 70 % unit — providers, services, models, utils, error classification, cache TTL
- 20 % widget — individual widgets, user interactions, state-driven UI, accessibility
- 10 % integration — full user flows, deep links, navigation guards
At time of writing the suite has 1400+ tests and runs in ~40 s locally.
Standing project rule: prefer fakes over mocks for the service layer. A fake is a small in-file class that fulfills the interface with explicit behaviour. Mocks with when(...).thenReturn(...) grow into untyped spaghetti.
Example:
class _FakeStationService extends StationService {
_FakeStationService({this.stationsToReturn = const [], this.failWith});
final List<Station> stationsToReturn;
final Exception? failWith;
@override
ServiceSource get source => ServiceSource.demo;
@override
Future<List<Station>> fetch(SearchParams params) async {
if (failWith != null) throw failWith!;
return stationsToReturn;
}
}mocktail is used only for widget-level callbacks (e.g. verify(() => onTap()).called(1)).
For bug fixes this is not optional — standing project rule:
-
Trace the UI first —
grepthe screen/widget file that triggers the bug. Read the exact method call. - Write a failing test that calls the EXACT same method the UI calls (not the "correct" one). The test must fail for the same reason the app fails.
- If the test passes immediately, it's testing the wrong thing. Re-read the UI file.
- Implement the fix — make the test pass.
- Run full suite — no regressions.
- Only THEN build APK — never build before the test proves the fix works.
- Ask "what else depends on this?" — grep all callers of any changed function/getter before asserting new behaviour in tests.
These rules came out of incidents where the cost of skipping each step was high enough to write it down — keep them.
test('searchStateProvider emits loading → data on success', () async {
final container = ProviderContainer(
overrides: [stationServiceProvider.overrideWithValue(
_FakeStationService(stationsToReturn: [testStation()]))],
);
addTearDown(container.dispose);
expect(container.read(searchStateProvider), const AsyncValue.loading());
await container.read(searchStateProvider.notifier).search(_params);
expect(container.read(searchStateProvider).requireValue.data, hasLength(1));
});test('fresh hit', () async { /* store now, getFresh returns */ });
test('stale hit', () async { /* store 10 min ago with 5 min TTL, get returns, getFresh returns null */ });
test('miss', () async { /* never stored, getFresh returns null */ });- API success — single call, no cache hit, result marked fresh
- API failure + stale cache — chain returns stale, errors populated
- Everything fails — throws
ServiceChainExhaustedExceptionwith all errors
Every exception type the app throws has an asserted category:
for (final case in [
(DioException(...), ErrorCategory.network),
(ApiException(...), ErrorCategory.api),
(CacheException(...), ErrorCategory.cache),
// ... 8+ categories total
]) {
test('${case.$1.runtimeType} maps to ${case.$2}', () {
expect(ErrorClassifier.classify(case.$1), case.$2);
});
}await tester.pumpWidget(
pumpApp(
overrides: [...standardTestOverrides, featureOverride],
child: const SearchScreen(),
),
);pumpApp lives in test/helpers/pump_app.dart — sets up MaterialApp, localisations, and provider scope in one call. standardTestOverrides (test/helpers/mock_providers.dart) gives safe fakes for all keep-alive providers.
Every interactive screen tests against the tap-target guideline:
testWidgets('SearchScreen passes tap-target guideline', (tester) async {
await tester.pumpWidget(pumpApp(child: const SearchScreen()));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
});Icon-button tooltip coverage is enforced by a static scan:
// test/accessibility/icon_button_tooltip_coverage_test.dart
// fails if any IconButton is missing a tooltipStation cards expose brand + address + price + open/closed as a single merged Semantics node, not a tree of isolated Text widgets. The test asserts the merged label:
expect(find.bySemanticsLabel(RegExp(r'Shell.*€.*open')), findsOneWidget);integration_test/ directory. Run on emulator:
flutter test integration_test/Cover:
- Consent gate → country setup → first search — the "golden path"
- Favorite toggle persists across app restart
- Price alert fires a notification when the background check is triggered manually
- Deep link
tankstellen://station/{id}opens the detail screen - Offline-to-online transition: stale data shown, then refreshed
test/lint/:
-
no_silent_catch_test.dart— fails if anycatch (_) {}is committed -
no_hardcoded_ui_strings_test.dart— fails if any user-facing string is hard-coded instead of routed throughAppLocalizations. The baseline may only ever decrease; the target is zero (epic #1657) -
file_length_test.dart— caps every file at 400 lines; the limit is the project's strongest signal that a file needs splitting -
no_raw_appbar_in_features_test.dart/no_raw_card_in_features_test.dart— force feature code through the design-system wrappers -
arb_fragments_consistency_test.dart— pins the ARB-fragment → top-level ARB build pipeline -
catch_block_stacktrace_coverage_test.dart— fails if acatch (e)swallows the stack trace
Static scans are the cheapest way to prevent regressions that would otherwise go through every code review.
Tests that exercise a code path which calls errorLogger.log(...) will, by default, flood test output with the spooled errors. Use the helper:
import 'package:tankstellen/core/telemetry/storage/isolate_error_spool.dart';
void main() {
silenceErrorLoggerSpool(); // top of main(), before any group()
group('GithubIssueReporter', () { ... });
}The pattern came out of Epic #2146 (309 silent-catch sites rerouted through errorLogger.log(ErrorLayer.<layer>, e, st, context: {...})) — every test file that exercises those code paths now opens with this one-liner so legitimate test output isn't drowned in spool noise. See Error Reporting & Tracing for the production logger contract.
flutter test --coverageReport: coverage/lcov.info. CI enforces a 45 % threshold on app code (excludes l10n/, *.g.dart, *.freezed.dart).
flutter test test/features/search/ # one feature tree
flutter test -p windows test/core/cache/ # one core area
flutter test -name 'caches fresh entries' # name patternTags:
flutter test --tags network # @Tags(['network']) — reachability tests
flutter test --exclude-tags golden- State Management (Riverpod) — provider override patterns
- Service Layer & Fallback — what the chain tests verify
- CI/CD Pipeline — where tests run on every PR
👤 User Guide
🛠️ Developer Guide
Architecture
Code patterns
Quality
Deep dives
Reference
Workflow