Quality is layered feedback
Tests do not prove that defects are absent; they reduce uncertainty around important behavior. A balanced strategy uses many fast domain tests, widget tests for user-visible behavior, and a small number of high-value integrated journeys.
Unit tests
test('applies a ten percent discount', () {
const calculator = DiscountCalculator();
final result = calculator.apply(total: 100, percentage: 10);
expect(result, 90);
});
Use Arrange, Act, and Assert, and name the behavior. A test should fail for one clear reason.
Fakes are often a better fit for small repositories because they have readable behavior. Mocks are useful when the interaction itself matters, such as verifying that an event was published exactly once.
Use case and state tests
Cover success, validation, known failure, and unexpected exceptions. For asynchronous controllers, assert the state sequence and control the clock, network, and storage.
test('publishes loading and data when products load', () async {
final repository = FakeProductRepository(products: sampleProducts);
final controller = ProductsController(GetProducts(repository));
final states = <ProductsState>[];
controller.addListener(() => states.add(controller.state));
await controller.load();
expect(states.first.isLoading, isTrue);
expect(states.last.items, sampleProducts);
});
Widget tests
Test what a person can perceive: content, enabled actions, validation, and state changes.
testWidgets('submits a valid form', (tester) async {
await tester.pumpWidget(buildTestApp(const ProductFormPage()));
await tester.enterText(find.byKey(const Key('product-name')), 'Notebook');
await tester.tap(find.text('Save'));
await tester.pump();
expect(find.text('Product saved'), findsOneWidget);
});
Use stable keys only when semantics or visible copy are not sufficient.
Integration and critical journeys
Automate a few complete flows: sign-in, catalog, order creation, and sign-out. Run against a controlled environment with isolated data and deterministic cleanup.
Static analysis and coverage
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --coverage
dart format --output=none --set-exit-if-changed .Checks formatting without modifying the checkout. --output=none prevents rewriting and --set-exit-if-changed turns drift into a non-zero exit code, making the rule enforceable in CI.
If it fails, run dart format . locally, review the diff, and repeat the gate.
flutter analyzeLoads Flutter context and runs Dart analyzer with project rules. CI should preserve the file, line, and diagnostic code so failure remains actionable.
Don’t use broad exclusions to hide newly introduced warnings. When an exception is legitimate, document it at the narrowest possible scope.
flutter test --coverageRuns the suite and writes coverage data, usually to coverage/lcov.info. A reporting tool must interpret the file; the number alone does not evaluate assertion strength.
Separate test failure from report generation or publication failure. The first invalidates behavior; the second reduces visibility and also needs treatment.
Coverage is a signal, not a standalone goal. Prioritize critical rules, errors, and transitions. One hundred percent line coverage can still hide weak assertions.
Think of the gate as layered feedback: formatting is fast and deterministic; analysis finds structural issues; unit tests cover rules; widget tests cover interaction; integrated journeys cover a few critical paths. The higher the layer, the greater the cost and the smaller its suite should be.
Observability without exposure
Logs should support diagnosis without recording tokens, passwords, personal payloads, or financial data. Prefer structured events with severity, operation, duration, outcome, and correlation ID.
Crash reporting needs separated environments, application version, and build symbols. Business events - successful login, order created, synchronization failed - complement technical exceptions.
Monitor stability, startup time, API failure, journey abandonment, and affected versions. Telemetry needs an owner and response process or it becomes expensive noise.
Guided practice: turn quality into evidence
Continue with Sales Management while all earlier tests are green. You will protect one domain rule, one asynchronous transition, one user-visible behavior, and one critical journey.
Checkpoint 1 — write the risk matrix first
Create docs/test-strategy.md:
| Risk | Layer | Minimum case |
|---|---|---|
| invalid product enters the domain | unit | blank name is rejected |
| loading ends in the wrong state | controller | loading → data and loading → error |
| failure offers no recovery | widget | error shows Try again |
| the main flow breaks across layers | integration | open catalog, create, find product |
This matrix is the suite contract. Test count and coverage percentage cannot replace named risks.
Checkpoint 2 — protect the cheapest rule first
Create test/features/products/domain/product_test.dart:
void main() {
group('Product.create', () {
test('rejects a blank name', () {
expect(
() => Product.create(id: 'p-1', name: ' ', priceInCents: 100),
throwsA(isA<ArgumentError>()),
);
});
test('normalizes a valid name', () {
final product = Product.create(
id: 'p-1',
name: ' Keyboard ',
priceInCents: 15990,
);
expect(product.name, 'Keyboard');
});
});
}
Run this file alone until it is green: flutter test test/features/products/domain/product_test.dart.
Checkpoint 3 — control asynchronous completion
Use a Completer in a fake so the intermediate state is observable:
test('publishes loading before products', () async {
final completer = Completer<List<Product>>();
final repository = ControlledProductRepository(completer.future);
final controller = ProductsController(GetProducts(repository));
final load = controller.load();
expect(controller.state, isA<ProductsLoading>());
completer.complete([sampleProduct]);
await load;
expect(controller.state, isA<ProductsData>());
});
Add an error completion too. Avoid Future.delayed; real time makes the test slower and less deterministic.
Checkpoint 4 — test visible behavior
In test/features/products/presentation/products_page_test.dart, mount the page with fakes and cover loading, data, and error:
testWidgets('offers retry after failure', (tester) async {
final controller = FakeProductsController.error();
await tester.pumpWidget(buildTestApp(
ProductsPage(controller: controller),
));
expect(find.text('Unable to load products'), findsOneWidget);
await tester.tap(find.text('Try again'));
expect(controller.loadCalls, 1);
});
Add form validation. Prefer visible copy and semantics; use a Key only when the interface has no stable selector.
Checkpoint 5 — automate one critical journey
flutter pub add --dev integration_test --sdk=flutter
flutter test integration_test/catalog_journey_test.dart
The journey opens the catalog, taps New product, fills the form, saves, and finds the new item. Use a deterministic fake or isolated emulators. A real-device run may require a connected target; record that dependency separately in the README.
Checkpoint 6 — observe without leaking secrets
Create lib/core/observability/app_logger.dart:
abstract interface class AppLogger {
void info(String event, Map<String, Object?> fields);
}
final class SafeLogger implements AppLogger {
SafeLogger(this._write);
final void Function(Map<String, Object?> event) _write;
static const _sensitive = {'password', 'token', 'authorization'};
@override
void info(String event, Map<String, Object?> fields) {
_write({
'event': event,
for (final entry in fields.entries)
entry.key: _sensitive.contains(entry.key.toLowerCase())
? '[REDACTED]'
: entry.value,
});
}
}
Test redaction, then record only event, duration, outcome, version, and correlation ID. Disable crash reporting in tests and tag the environment before sending telemetry.
Checkpoint 7 — run the complete gate
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --coverage
Inspect coverage/lcov.info with a report tool and look for unprotected critical rules and branches. Do not inflate coverage with empty assertions.
| Symptom | Likely cause | Fix |
|---|---|---|
pumpAndSettle never ends | animation or stream stays active | pump specific frames |
| async test is flaky | real clock or network | inject a clock, fake, or emulator |
| widget cannot find copy | locale or state differs | mount locale and dependencies explicitly |
| log still exposes a secret | redaction checks too few field names | centralize and test the policy |
Completion gate and independent practice
Finish when every matrix risk has evidence, the gate passes, and an intentional regression makes the correct test fail. Revert that regression afterward.
Independently add a persistence failure to product creation. Preserve entered values, explain the failure, allow retry, and protect it with controller and widget tests.
Terms to review: test double, fake, mock, deterministic test, widget test, integration test, coverage, structured log, trace, and metric. See the course reference.