Kodokon kodokon.com

Tests: Dart unit tests and widget tests

Test logic and widgets with hand-written fakes, a simulated clock and the right pump reflexes.

11 min · 3 questions

Open this lesson in Kodokon

The Flutter test pyramid: a broad base of pure Dart unit tests, a layer of widget tests, a few integration tests. A unit test should never import material.dart - if your logic requires it, the problem is architectural, not technical. To isolate code from its dependencies, prefer hand-written fakes to generated mocks: a fake is a simplified but genuinely functional implementation of the contract (in-memory repository, controllable clock); it survives refactorings and reads like normal code. Mocks, which verify interactions ("this method was called twice"), couple the test to implementation details - reserve them for protocols where the interaction is the behavior.

DART
import 'package:flutter/foundation.dart';

abstract interface class ScoreRepository {
  Future<int> fetchBest();
}

class ScoreController extends ChangeNotifier {
  ScoreController(this._repository);

  final ScoreRepository _repository;

  int? best;
  Object? error;

  Future<void> load() async {
    try {
      best = await _repository.fetchBest();
      error = null;
    } on Exception catch (e) {
      error = e;
    }
    notifyListeners();
  }
}
The code under test: no import of material.dart.

setUp recreates the objects before each test: no shared state, so no test that depends on the execution order - that's the condition of their reliability. Composable matchers (isA<Exception>(), throwsA, isNull) produce precise failure messages, far more useful than a boolean. Notice the second test below: it verifies the failure then the recovery right after. Error paths are code like any other - if they aren't tested, consider them broken.

DART
import 'package:flutter_test/flutter_test.dart';

class FakeScoreRepository implements ScoreRepository {
  int calls = 0;
  bool failNextCall = false;

  @override
  Future<int> fetchBest() async {
    calls++;
    if (failNextCall) {
      failNextCall = false;
      throw Exception('network unavailable');
    }
    return 42;
  }
}

void main() {
  late FakeScoreRepository repository;
  late ScoreController controller;

  setUp(() {
    repository = FakeScoreRepository();
    controller = ScoreController(repository);
  });

  test('exposes the best score', () async {
    await controller.load();

    expect(controller.best, 42);
    expect(controller.error, isNull);
    expect(repository.calls, 1);
  });

  test('captures an error then recovers', () async {
    repository.failNextCall = true;

    await controller.load();
    expect(controller.error, isA<Exception>());

    await controller.load();
    expect(controller.best, 42);
    expect(controller.error, isNull);
  });

  test('notifies its listeners', () async {
    var notifications = 0;
    controller.addListener(() => notifications++);

    await controller.load();

    expect(notifications, 1);
  });
}
A fifteen-line fake replaces an entire library of mocks.

A widget test renders nothing to the screen: it runs in a specialized binding (AutomatedTestWidgetsFlutterBinding) where time is simulated and where each frame is produced on demand. pumpWidget mounts the tree; pump() advances the fake clock and produces one frame; pump(const Duration(milliseconds: 150)) jumps in time - ideal for freezing an intermediate animation state and inspecting it. The Finders (find.text, find.byType, find.byKey) query the real tree of Elements, not a screenshot. Because nothing is truly asynchronous - neither clock nor network - these tests are deterministic: a failure is always reproducible.

DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class BadgeScreen extends StatefulWidget {
  const BadgeScreen({super.key});

  @override
  State<BadgeScreen> createState() => _BadgeScreenState();
}

class _BadgeScreenState extends State<BadgeScreen> {
  bool _visible = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          if (_visible) const Text('New'),
          IconButton(
            icon: const Icon(Icons.close),
            onPressed: () =>
                setState(() => _visible = false),
          ),
        ],
      ),
    );
  }
}

void main() {
  testWidgets('hides the badge after the tap',
      (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: BadgeScreen()),
    );
    expect(find.text('New'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.close));
    await tester.pump();

    expect(find.text('New'), findsNothing);
  });
}
tap triggers no frame: the pump that follows is required.
BASH
flutter test
flutter test test/score_controller_test.dart
flutter test --coverage
flutter test --update-goldens
Target a file, measure coverage, regenerate the goldens.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Why does pumpAndSettle fail against an animation started with repeat()?
    • It pumps frames as long as any remain scheduled: an infinite animation never stabilizes, hence a timeout
    • repeat() isn't supported by the test binding
    • pumpAndSettle only handles implicit animations
  2. What's the difference between a fake and a mock?
    • A fake is a simplified but functional implementation; a mock serves mainly to verify interactions
    • The mock is hand-written, the fake is generated by a tool
    • None: the two terms are synonyms
    • A fake can't implement a Dart interface
  3. In a widget test, what exactly does await tester.pump() do?
    • It advances the simulated clock and produces a single new frame
    • It waits for all running animations to finish
    • It restarts the test from the beginning
    • It waits for real network requests to finish