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

CI flutter analyze

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

CI flutter test --coverage

Runs 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:

RiskLayerMinimum case
invalid product enters the domainunitblank name is rejected
loading ends in the wrong statecontrollerloading → data and loading → error
failure offers no recoverywidgeterror shows Try again
the main flow breaks across layersintegrationopen 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.

SymptomLikely causeFix
pumpAndSettle never endsanimation or stream stays activepump specific frames
async test is flakyreal clock or networkinject a clock, fake, or emulator
widget cannot find copylocale or state differsmount locale and dependencies explicitly
log still exposes a secretredaction checks too few field namescentralize 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.