Week 16: Unit & Widget Testing with flutter_test

Every screen built over the past fifteen weeks has been verified by hand, in a simulator. This week starts building a real safety net — unit tests for plain Dart logic and widget tests that render and interact with real widgets, using flutter_test, which ships with every Flutter SDK.

Phase 7 of 8 Week 16 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a unit test for plain Dart logic, independent of any widget
  • Write a widget test using testWidgets, pumpWidget, and finders
  • Simulate user interaction in a widget test and assert on the result
  • Mock a dependency with Mockito so a widget test doesn't depend on a real network call

1. Unit Testing Plain Dart Logic

test/cart_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/cart_notifier.dart';

void main() {
  test('adding an item increases the cart length', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    container.read(cartProvider.notifier).add('Widget');

    expect(container.read(cartProvider).length, 1);
  });

  test('removing an item that was never added leaves the cart unchanged', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    container.read(cartProvider.notifier).remove('Nonexistent');

    expect(container.read(cartProvider), isEmpty);
  });
}

This is exactly the Riverpod ProviderContainer pattern from Week 8 — plain Dart logic, tested with no widget tree, no simulator, and near-instant test execution. This kind of test is where the bulk of business-logic coverage should live.

2. Widget Tests: pumpWidget & Finders

test/product_card_test.dart
testWidgets('ProductCard shows the product name and price', (tester) async {
    await tester.pumpWidget(
      MaterialApp(home: ProductCard(product: Product(id: 1, name: 'Mouse', price: 1299))),
    );

    expect(find.text('Mouse'), findsOneWidget);
    expect(find.text('₹1299'), findsOneWidget);
});

pumpWidget builds the widget tree in a test environment; find.text, find.byType, and find.byKey locate elements within it — the same "find the way a user would perceive it" philosophy this site's React Native course teaches for its own testing library.

3. Simulating Interaction

tapping a button and asserting the result
testWidgets('tapping Add to Cart increases the cart badge', (tester) async {
    await tester.pumpWidget(
      const ProviderScope(child: MaterialApp(home: ProductListScreen())),
    );

    expect(find.text('0'), findsOneWidget); // starting badge count

    await tester.tap(find.text('Add to Cart').first);
    await tester.pump(); // rebuild after the state change

    expect(find.text('1'), findsOneWidget);
});

tester.pump() after an interaction is not optional — widget tests don't auto-rebuild the way a running app does; without it, the assertion checks the pre-interaction frame and fails even though the tap itself worked correctly. For an animation or anything with a delay, tester.pumpAndSettle() pumps repeatedly until no more frames are scheduled.

4. Mocking with Mockito

terminal
flutter pub add --dev mockito build_runner
mocking a repository so a test doesn't hit a real network
@GenerateMocks([ProductRepository])
import 'product_list_test.mocks.dart';

testWidgets('shows products from a mocked repository', (tester) async {
    final mockRepo = MockProductRepository();
    when(mockRepo.fetchProducts()).thenAnswer(
      (_) async => [Product(id: 1, name: 'Mouse', price: 1299)],
    );

    await tester.pumpWidget(ProviderScope(
      overrides: [productRepositoryProvider.overrideWithValue(mockRepo)],
      child: const MaterialApp(home: ProductListScreen()),
    ));
    await tester.pumpAndSettle();

    expect(find.text('Mouse'), findsOneWidget);
});

Riverpod's overrideWithValue is what makes this clean — the exact same production widget code runs in the test, just backed by a mock repository instead of a real network call, with no conditional "if testing" branches anywhere in the app code itself.

5. Hands-on Exercise

Hands-on

Add real test coverage to the app

Write unit and widget tests covering a notifier, a widget, an interaction, and a mocked dependency.

Requirements:

  1. A unit test suite for the CartNotifier from Week 8, covering add, remove, and at least one edge case (removing something never added).
  2. A widget test for a single component (e.g. ProductCard) asserting on its rendered content with finders.
  3. A widget test simulating a tap that changes state, asserting the UI reflects it after an explicit tester.pump().
  4. A widget test using Mockito (or Riverpod's overrideWithValue with a hand-written fake) to test a screen backed by Week 9's networking without making a real HTTP call.
  5. Run flutter test and confirm all tests pass, including intentionally breaking one assertion temporarily to confirm the suite actually fails when it should.
Hint

If a widget test's assertion fails right after a simulated tap even though the tap handler is definitely correct, you almost certainly forgot await tester.pump() (or pumpAndSettle() for anything animated) immediately after the tap — the test framework does not automatically rebuild the widget tree after every simulated interaction the way a live app does.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why should the bulk of an app's business-logic test coverage live in plain unit tests rather than widget tests, where possible?

A unit test against a notifier or plain Dart function runs near-instantly with no widget tree, no rendering, and no simulator overhead — the same logic tested through a widget test would carry all of that extra setup cost for no additional confidence, since the actual behavior being verified (does adding an item increase the count) doesn't require a UI at all.

Q2

What does tester.pump() actually do, and why does forgetting it after a simulated interaction cause a test to see stale state?

It triggers Flutter to process a frame — running any pending rebuilds, including ones triggered by state changes from the interaction just simulated. Without calling it, the test's subsequent assertions still see the widget tree as it looked immediately after pumpWidget, before the interaction's state change has actually been reflected in a rebuild.

Q3

Why does mocking the repository (rather than letting the test hit a real network) make the test both faster and more reliable?

A real network call is slow relative to in-memory test execution, and its success depends on external factors (server availability, network conditions) entirely outside the test's control — a flaky network turns a correct test into an intermittently failing one for reasons unrelated to the code being tested. A mock returns a fixed, predictable result instantly, isolating the test to just the widget logic being verified.

Q4

Why does using Riverpod's overrideWithValue to inject a mock repository let the exact same production widget code run unmodified in the test?

The widget only ever depends on the abstract productRepositoryProvider, never on a concrete implementation directly — swapping which implementation that provider resolves to (via overrideWithValue in the test's ProviderScope) changes what data the widget receives without requiring the widget's own code to know or care whether it's running against a mock or the real thing.