Emmanuel Akinfulubi

Testing and performance all 6 written

Unit, widget and golden tests; then finding and fixing jank with evidence rather than guesses.

30 Unit tests, and what a fake should not do

A unit test is worth having when it can fail for a reason you care about. The commonest way to lose that property is not a missing test — it is a test double that quietly took over the thing being tested, so the suite stays green no matter what the production code does.

The five words, used precisely

People use these interchangeably and interviewers notice. They are five different objects with five different jobs.

  • Dummy — passed only to fill a parameter. Never called. If it is called, the test is wrong.
  • Stub — returns canned answers. No logic, no recording. "When asked for user 7, hand back this map."
  • Fake — a real working implementation of the port, simpler than production. An in-memory store instead of SQLite. It has behaviour, and that is the risk this lesson is about.
  • Spy — records what it was called with, and the test inspects the record afterwards. Assertions come after the act.
  • Mock — expectations are set before the act, and the object itself fails the test if they are not met. Assertions come first.

A fake, a stub and a spy, each doing only its own job

ran and analysed
/// The port. Everything the feature needs from storage, and nothing else.
abstract class Notes {
  Future<void> save(String id, String body);
  Future<String?> read(String id);
}

/// FAKE: a working implementation. It obeys the port's contract - save then read
/// gives you back what you saved - and knows nothing about what notes mean.
class InMemoryNotes implements Notes {
  final _rows = <String, String>{};

  @override
  Future<void> save(String id, String body) async => _rows[id] = body;

  @override
  Future<String?> read(String id) async => _rows[id];
}

/// STUB: canned answers, no storage, no rules.
class StubNotes implements Notes {
  StubNotes(this._answers);
  final Map<String, String> _answers;

  @override
  Future<void> save(String id, String body) async {}

  @override
  Future<String?> read(String id) async => _answers[id];
}

/// SPY: wraps something real and writes down every call, in order.
class SpyNotes implements Notes {
  SpyNotes(this._inner);
  final Notes _inner;
  final calls = <String>[];

  @override
  Future<void> save(String id, String body) {
    calls.add('save $id');
    return _inner.save(id, body);
  }

  @override
  Future<String?> read(String id) {
    calls.add('read $id');
    return _inner.read(id);
  }
}

/// The code under test. It writes, then reads back to confirm.
class NoteEditor {
  NoteEditor(this._notes);
  final Notes _notes;

  Future<bool> publish(String id, String body) async {
    await _notes.save(id, body);
    return await _notes.read(id) == body;
  }
}

Future<void> main() async {
  final fake = InMemoryNotes();
  final spy = SpyNotes(fake);
  final published = await NoteEditor(spy).publish('n1', 'hello');

  final stub = StubNotes({'n2': 'canned'});
  final fromStub = await stub.read('n2');

  print('spy saw: ${spy.calls}');
  print('stub gave: $fromStub');

  test('the fake behaves like storage, so the editor works', published, true);
  test('the spy recorded the calls, in order', spy.calls, ['save n1', 'read n1']);
  test('the stub returns its canned answer', fromStub, 'canned');
  test('and forgets anything you save into it', await stub.read('n1'), null);
}

What it printed, when the build ran it

spy saw: [save n1, read n1]
stub gave: canned
ok   the fake behaves like storage, so the editor works
ok   the spy recorded the calls, in order
ok   the stub returns its canned answer
ok   and forgets anything you save into it

Note what the fake did and did not know. It knew that save followed by read returns the same bytes, because that is the port's contract. It knew nothing about publishing, validity or note length, because those are the rules that NoteEditor owns. That boundary is the whole subject of this lesson.

What a fake should not do

Here is the failure, with numbers. A basket costs £50.00 net, the customer has a ten per cent discount, and VAT is twenty per cent. The specification says discount first, then VAT on what is left: 4500 + 900 = 5400 pence. The production code applies VAT to the full amount and subtracts the discount afterwards, which is 6000 - 500 = 5500 — eleven pence per pound too much, on every order.

The test was green the whole time, because the expected value came from the double, and the double had the same rule copied into it.

A green test over a wrong answer, because the double supplied the expectation

ran and analysed
/// PRODUCTION. VAT is applied before the discount, which is the bug.
int totalPence(int netPence, int discountPercent, int vatPercent) {
  final withVat = netPence + (netPence * vatPercent) ~/ 100;
  return withVat - (netPence * discountPercent) ~/ 100;
}

/// THE BAD DOUBLE. Written by the same person on the same afternoon, so it makes
/// the same wrong choice. The test asks it what to expect, and it agrees.
class VatFake {
  int expected(int netPence, int discountPercent, int vatPercent) {
    final withVat = netPence + (netPence * vatPercent) ~/ 100;
    return withVat - (netPence * discountPercent) ~/ 100;
  }
}

/// The rule as the finance team wrote it down: discount, then VAT on the remainder.
int fromSpecification(int netPence, int discountPercent, int vatPercent) {
  final afterDiscount = netPence - (netPence * discountPercent) ~/ 100;
  return afterDiscount + (afterDiscount * vatPercent) ~/ 100;
}

void main() {
  const net = 5000, discount = 10, vat = 20;
  final code = totalPence(net, discount, vat);
  final fake = VatFake().expected(net, discount, vat);
  final spec = fromSpecification(net, discount, vat);

  print('production: $code   fake: $fake   specification: $spec');
  print('overcharge per order: ${code - spec} pence');

  test('the production code charges', code, 5500);
  test('the fake, asked what to expect, says the same', fake, 5500);
  test('so the test passes, and has proven nothing', code == fake, true);
  test('the number from the specification is different', spec, 5400);
  test('every order was 100 pence wrong, for as long as that test was green',
      code - spec, 100);
}

What it printed, when the build ran it

production: 5500   fake: 5500   specification: 5400
overcharge per order: 100 pence
ok   the production code charges
ok   the fake, asked what to expect, says the same
ok   so the test passes, and has proven nothing
ok   the number from the specification is different
ok   every order was 100 pence wrong, for as long as that test was green

The rule, stated so you can say it in an interview. A double may hold data and it may hold the contract of the port. It must never hold a business rule that the code under test also holds, because then the assertion is f(x) == f(x) and it is true by construction. The expected value in a test should come from outside the codebase: the specification, a worked example, a number somebody in the business can confirm.

Three practical tells that you are heading for this. The double has an if in it that mirrors an if in production. The test's expected value is a call rather than a literal. And the double had to change when a rule changed, which means the rule was in two places.

Why hand-rolled doubles catch what dynamic mocks do not

A fake you wrote is a class that implements the port, so the compiler checks it. Add a method to the port and every fake in the suite stops compiling until somebody decides what it should do. That is a feature. A generated mock is happy to answer null forever.

This does not compile, on purpose

does not compile
/// The port grows a method, because the feature needed one.
abstract class Notes {
  Future<void> save(String id, String body);
  Future<String?> read(String id);
  Future<void> delete(String id);
}

/// The fake was written before delete existed. The analyser stops the build until
/// somebody decides what deleting means for an in-memory store.
class InMemoryNotes implements Notes {
  final _rows = <String, String>{};

  @override
  Future<void> save(String id, String body) async => _rows[id] = body;

  @override
  Future<String?> read(String id) async => _rows[id];
}

void main() {
  print(InMemoryNotes());
}

What the analyser said

line 10: Missing concrete implementation of 'Notes.delete'. Try implementing the missing method, or make the class abstract.
[non_abstract_class_inherits_abstract_member]

The error path, which is where the bugs are

Most suites test the happy path and one obvious failure. The interesting cases are the ones a stub makes easy and a real dependency makes almost impossible: the second call failing after the first succeeded, a timeout, a response that parses but is empty.

A stub that fails on the second call only

ran and analysed
/// The failure the domain understands, rather than whatever the transport threw.
sealed class Outcome {}

class Loaded extends Outcome {
  Loaded(this.body);
  final String body;
}

class Offline extends Outcome {}

class Broken extends Outcome {
  Broken(this.detail);
  final String detail;
}

/// The transport's failure, as a type rather than a string to grep.
class SocketDown implements Exception {}

abstract class Api {
  Future<String> get(String path);
}

/// A stub that fails on the nth call. Two lines, and it buys you the case that
/// costs a day to reproduce against a real server.
class FlakyApi implements Api {
  FlakyApi({required this.failOnCall, required this.error});
  final int failOnCall;
  final Object error;
  int calls = 0;

  @override
  Future<String> get(String path) async {
    calls++;
    if (calls == failOnCall) throw error;
    return 'body of $path';
  }
}

class Feed {
  Feed(this._api);
  final Api _api;

  Future<Outcome> load(String path) async {
    try {
      final body = await _api.get(path);
      return body.isEmpty ? Broken('empty response') : Loaded(body);
    } on SocketDown {
      return Offline();
    } on Exception catch (error) {
      return Broken('$error');
    }
  }
}

Future<void> main() async {
  final api = FlakyApi(failOnCall: 2, error: SocketDown());
  final feed = Feed(api);

  final first = await feed.load('/a');
  final second = await feed.load('/b');
  final third = await feed.load('/c');

  print('${first.runtimeType}, ${second.runtimeType}, ${third.runtimeType}');

  test('the first call succeeds', first is Loaded, true);
  test('the second is mapped to a domain failure, not an Exception', second is Offline, true);
  test('and the third recovers, because the stub said so', third is Loaded, true);
  test('the stub counted the calls, which is the spy half doing its job', api.calls, 3);
}

What it printed, when the build ran it

Loaded, Offline, Loaded
ok   the first call succeeds
ok   the second is mapped to a domain failure, not an Exception
ok   and the third recovers, because the stub said so
ok   the stub counted the calls, which is the spy half doing its job

DateTime.now() is a dependency

A function that reads the clock cannot be tested at its boundaries, and boundaries are where date logic breaks. The fix is one parameter. Both versions are below; only one of them can be asked an interesting question.

One parameter is the difference between a real test and a tautology

ran and analysed
const window = Duration(days: 30);

/// Reads the clock itself. There is no way to ask it about day 30.
class Untestable {
  bool isExpired(DateTime issued) => DateTime.now().difference(issued) > window;
}

/// The clock arrives as a parameter. Default it in production, pass it in tests.
class Testable {
  Testable({DateTime Function()? now}) : _now = now ?? DateTime.now;
  final DateTime Function() _now;

  bool isExpired(DateTime issued) => _now().difference(issued) > window;
}

/// The same class with the rule broken, to show what the weak test misses.
class TestableButWrong {
  TestableButWrong(this._now);
  final DateTime Function() _now;

  bool isExpired(DateTime issued) =>
      _now().difference(issued) > const Duration(days: 3000);
}

void main() {
  final issued = DateTime.utc(2024, 1, 1);
  DateTime Function() fixed(int day) => () => DateTime.utc(2024, 1, day);

  final subject = Testable(now: fixed(31));      // exactly 30 days later
  final justOver = Testable(now: fixed(32));     // 31 days later

  print('day 30: ${subject.isExpired(issued)}, day 31: ${justOver.isExpired(issued)}');
  print('the weak assertion on the untestable class: '
      '${Untestable().isExpired(DateTime.now())}');

  test('exactly 30 days is not expired, which is the line that matters',
      subject.isExpired(issued), false);
  test('31 days is', justOver.isExpired(issued), true);
  test('all the untestable version lets you assert is that today is not expired',
      Untestable().isExpired(DateTime.now()), false);
  test('and that assertion passes with the rule set to 3000 days, so it checks nothing',
      TestableButWrong(DateTime.now).isExpired(DateTime.now()), false);
}

What it printed, when the build ran it

day 30: false, day 31: true
the weak assertion on the untestable class: false
ok   exactly 30 days is not expired, which is the line that matters
ok   31 days is
ok   all the untestable version lets you assert is that today is not expired
ok   and that assertion passes with the rule set to 3000 days, so it checks nothing

The last two assertions are the argument. The untestable class does pass a test — the test just also passes for a version of the code that is wrong by a factor of a hundred. A test that cannot distinguish correct from broken is not a weak test, it is a cost with no benefit. The same reasoning applies to Random, to the device locale, to Platform.isIOS and to anything else that reads the world instead of being told about it.

The packages, for recognition

mocktail, which is mockito without the code generation

shown, not run
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class MockNotes extends Mock implements Notes {}

void main() {
  test('publish writes then reads back', () async {
    final notes = MockNotes();

    // STUBBING: canned answers, declared up front.
    when(() => notes.save(any(), any())).thenAnswer((_) async {});
    when(() => notes.read('n1')).thenAnswer((_) async => 'hello');

    final ok = await NoteEditor(notes).publish('n1', 'hello');
    expect(ok, isTrue);

    // VERIFYING: the spy half. Order matters here, and verifyInOrder checks it.
    verifyInOrder([
      () => notes.save('n1', 'hello'),
      () => notes.read('n1'),
    ]);
    verifyNoMoreInteractions(notes);
  });
}

mocktail is not installed, and this course adds no third-party package to a panel it claims to have verified. Every double above was hand-rolled and executed; this listing is here so the API is recognisable.

Two notes on choosing. mockito needs build_runner and a generated file per mock; mocktail needs neither, which is why new projects tend to pick it. And whichever you use, verifyNoMoreInteractions is the assertion people forget — without it, a test that checks two calls happened says nothing about the six others.

Over-verification is the other failure mode. A test that asserts the exact sequence of calls to a collaborator is a test of your current implementation, not of your behaviour, and it will fail on every harmless refactor. Verify calls when the call is the outcome — an analytics event was sent, a row was written, an email went out. When the outcome is a returned value, assert the value and let the calls be whatever they are.

The short version, if you get one minute on this. Test the unit's behaviour, not its collaborators. Keep the expected value outside the code. Prefer a hand-rolled fake for a port you own, because the compiler then maintains it for you. Use a mock when the interaction is the point. And inject anything that reads the world — the clock first, because dates are where the boundary bugs live.

31 Widget tests: pump, settle and the traps

A widget test builds a real widget tree, in a real (headless) framework, with a fake clock you drive by hand. Nothing happens between your lines of code unless you ask for it. Almost every confusing widget test is that sentence, forgotten.

Time does not pass unless you pump it

  • await tester.pump() — build exactly one frame, advancing the clock by nothing.
  • await tester.pump(duration) — advance the fake clock by duration, then build one frame. This is how you land in the middle of an animation on purpose.
  • await tester.pumpAndSettle() — pump repeatedly, in 100ms steps, until no frame is scheduled. Convenient, and the source of the worst failure in this lesson.
  • await tester.runAsync(() async {...}) — step outside the fake clock for real asynchronous work, such as a platform channel reply. Rarely needed, and lesson 39 needs it.

Three different pumps, and what each one is for

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// A 300ms linear fade, started by a tap.
class Fader extends StatefulWidget {
  const Fader({super.key});

  @override
  State<Fader> createState() => _FaderState();
}

class _FaderState extends State<Fader> with SingleTickerProviderStateMixin {
  late final AnimationController fade =
      AnimationController(vsync: this, duration: const Duration(milliseconds: 300));

  @override
  void dispose() {
    fade.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextButton(onPressed: fade.forward, child: const Text('reveal')),
        FadeTransition(
          key: const Key('fade'),
          opacity: fade,
          child: const Text('the answer'),
        ),
      ],
    );
  }
}

/// MaterialApp contains FadeTransitions of its own, for route transitions. Finding
/// by type would match several of them, so the one under test carries a key.
double opacityNow(WidgetTester tester) =>
    tester.widget<FadeTransition>(find.byKey(const Key('fade'))).opacity.value;

void main() {
  testWidgets('pump advances the clock by hand; pumpAndSettle runs to the end', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: Fader()));
    expect(opacityNow(tester), 0.0);

    await tester.tap(find.text('reveal'));
    await tester.pump();
    expect(opacityNow(tester), 0.0, reason: 'this is frame zero of the animation');

    // Land halfway through, deliberately. No real time passes here at all.
    await tester.pump(const Duration(milliseconds: 150));
    expect(opacityNow(tester), closeTo(0.5, 0.01));

    final frames = await tester.pumpAndSettle();
    expect(opacityNow(tester), 1.0);
    expect(frames, greaterThan(1), reason: 'it kept pumping until nothing was scheduled');
  });
}

Result

This ran as a real widget test during the build, and passed.

The pump(150ms) line is the reason to learn this properly. Testing what a widget looks like during a transition is otherwise impossible, and mid-transition is where the interesting bugs are — the button that is tappable while it is still fading in, the loading spinner that flashes for one frame.

Trap one: the action with no pump after it

tester.tap dispatches the gesture, so your onPressed really runs and setState really marks the element dirty. What it does not do is build a frame. Until you pump, the tree you are asserting against is the one from before the tap, and your test fails with a message that makes no sense.

The missing pump, shown from both sides

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

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

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int n = 0;

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => setState(() => n++),
      child: Text('count $n'),
    );
  }
}

void main() {
  testWidgets('the tap runs, and the screen does not change until you pump', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: Counter()));
    expect(find.text('count 0'), findsOneWidget);

    await tester.tap(find.byType(TextButton));

    // The handler has already run and n is 1. No frame has been built, so the
    // widget tree still says 0. This is the state most confusing test failures
    // are actually in.
    expect(find.text('count 0'), findsOneWidget, reason: 'no frame has been built yet');
    expect(find.text('count 1'), findsNothing);

    await tester.pump();
    expect(find.text('count 1'), findsOneWidget);
  });
}

Result

This ran as a real widget test during the build, and passed.

The same applies to enterText, drag, fling and longPress. All of them are "do the thing"; none of them is "and draw".

Trap two: pumpAndSettle on something that never settles

pumpAndSettle keeps pumping while a frame is scheduled. A CircularProgressIndicator, a shimmer placeholder, a looping brand animation and anything else driven by repeat() schedules a frame forever, so pumpAndSettle never returns. Its default budget is ten minutes of test clock, which in practice means your CI job dies without a useful message.

Below it is provoked on purpose with a two second budget, so it fails in milliseconds of real time and the failure can be asserted.

The hang, provoked deliberately and caught

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// A brand spinner. repeat() means a frame is always scheduled.
class Spinner extends StatefulWidget {
  const Spinner({super.key});

  @override
  State<Spinner> createState() => _SpinnerState();
}

class _SpinnerState extends State<Spinner> with SingleTickerProviderStateMixin {
  late final AnimationController spin =
      AnimationController(vsync: this, duration: const Duration(milliseconds: 300))..repeat();

  @override
  void dispose() {
    spin.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) =>
      RotationTransition(turns: spin, child: const Icon(Icons.refresh));
}

void main() {
  testWidgets('pumpAndSettle cannot settle an animation that repeats', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: Spinner()));

    Object? thrown;
    try {
      // The third argument is the timeout. Two seconds of TEST clock, so this
      // costs no real time; the default is ten minutes and looks like a hang.
      await tester.pumpAndSettle(
        const Duration(milliseconds: 100),
        EnginePhase.sendSemanticsUpdate,
        const Duration(seconds: 2),
      );
    } catch (error) {
      thrown = error;
    }

    expect(thrown, isA<FlutterError>());
    expect('$thrown', contains('pumpAndSettle timed out'));

    // pump(duration) has no such problem: it advances the clock a fixed amount
    // and builds one frame, whether or not anything is still animating.
    await tester.pump(const Duration(milliseconds: 50));
    expect(find.byType(RotationTransition), findsOneWidget);

    // Take the widget off screen so the ticker is disposed before the test ends.
    await tester.pumpWidget(const MaterialApp(home: SizedBox()));
  });
}

Result

This ran as a real widget test during the build, and passed.

What to do instead. Use pump(duration) with a duration you chose, or pump a fixed number of frames in a loop. If a screen has both a real transition to wait for and a permanent spinner, either take the spinner off with a flag the test can set, or wrap the wait so it tolerates the timeout. And be suspicious of pumpAndSettle generally: it hides how many frames your change costs, which is exactly the number lesson 33 wants you to be watching.

Driving the widget: taps, text, drags, and the exception you never saw

Errors thrown inside a build, a gesture callback or a layout are caught by the framework and reported, not rethrown into your test. tester.takeException() is how you collect one — and it is also how a test proves that something threw on purpose. If you never call it and something threw, the test fails at the end with the framework's dump, which is at least honest.

enterText, drag, addTearDown and takeException

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class Screen extends StatelessWidget {
  const Screen({super.key, required this.email});
  final TextEditingController email;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SizedBox(
          height: 200,
          child: ListView.builder(
            itemExtent: 60,
            itemCount: 40,
            itemBuilder: (context, i) => Text('row $i'),
          ),
        ),
        TextField(controller: email),
        TextButton(
          onPressed: () => throw StateError('no session'),
          child: const Text('submit'),
        ),
      ],
    );
  }
}

void main() {
  testWidgets('type, drag, and collect the exception the framework swallowed', (tester) async {
    // addTearDown runs after the test whether it passed or failed, which is the
    // right place for anything the test itself owns.
    final email = TextEditingController();
    addTearDown(email.dispose);

    await tester.pumpWidget(MaterialApp(home: Scaffold(body: Screen(email: email))));

    await tester.enterText(find.byType(TextField), 'ada@example.com');
    await tester.pump();
    expect(email.text, 'ada@example.com');

    expect(find.text('row 0'), findsOneWidget);
    await tester.drag(find.byType(ListView), const Offset(0, -300));
    await tester.pump();
    expect(find.text('row 0'), findsNothing, reason: '300px at 60px a row scrolled five off');
    expect(find.text('row 6'), findsOneWidget);

    await tester.tap(find.text('submit'));
    await tester.pump();
    expect(tester.takeException(), isA<StateError>());
  });
}

Result

This ran as a real widget test during the build, and passed.

Worth knowing about the finders themselves. find.byType matches every instance, including the ones MaterialApp and Scaffold build for you, so it is often ambiguous. find.byKey is unambiguous and survives a copy change. find.widgetWithText(ElevatedButton, 'Save') says what you mean. find.descendant(of: ..., matching: ...) is how you scope to one card in a list. And find.text is the most brittle of the lot, because it fails on a translation.

Trap three: the test that passes because it found nothing

This is the one that survives code review, because it reads exactly like a correct test. findsNothing is true when the widget is absent — and it is also true when the finder is looking for something that never existed, when the screen failed to build, and when the screen is blank. Here are two such assertions passing against a screen with nothing on it at all.

Two green assertions that would pass against a blank screen

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class SaveBar extends StatelessWidget {
  const SaveBar({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      key: const Key('save'),
      onPressed: () {},
      child: const Text('Save changes'),
    );
  }
}

void main() {
  testWidgets('findsNothing is true for more reasons than you meant', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SaveBar())));

    // The check the author intended: no error banner is showing.
    expect(find.text('Could not save'), findsNothing);
    // The same line with a typo in it. Also green, forever.
    expect(find.text('Save chnages'), findsNothing);

    // Now the same two assertions against a screen containing nothing whatsoever.
    // Both still pass, which means neither was ever testing the screen.
    await tester.pumpWidget(const MaterialApp(home: SizedBox()));
    expect(find.text('Could not save'), findsNothing);
    expect(find.text('Save chnages'), findsNothing);

    // The pairing that makes a negative assertion mean something: assert that the
    // thing which SHOULD be there is there, in the same test.
    await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SaveBar())));
    expect(find.byKey(const Key('save')), findsOneWidget);
    expect(find.widgetWithText(ElevatedButton, 'Save changes'), findsOneWidget);
  });
}

Result

This ran as a real widget test during the build, and passed.

The habit that fixes it: every findsNothing gets a findsOneWidget next to it. If you are asserting the error banner is gone, also assert the content is there. And when you write a new test, break the production code on purpose once and watch it fail. A test you have never seen fail is a test you have not finished writing.

Two more things a widget test is not. It has no images, no fonts by default and a fixed 800 by 600 logical window, so it cannot tell you whether the design looks right — that is lesson 32. And it has no platform, so anything behind a MethodChannel returns nothing unless you mock it, which is lesson 39. Knowing the boundary of the tool is most of knowing how to use it.

32 Golden tests, and why they break

A golden test renders a widget to an image and compares it, pixel for pixel, against a PNG checked into the repository. It is the only automated test that can fail because something looks wrong, and the only one whose failures are routinely nobody's fault.

What it catches that a widget test cannot

  • Anything about appearance. A widget test can assert that a Text exists. It cannot notice that the text is white on white, clipped, behind the keyboard, or eleven pixels off the baseline.
  • Regressions nobody was looking for. Change a theme colour and every golden in the app fails at once. That is the feature: the blast radius shows up as a number.
  • The whole-screen result of small changes. A padding change of 4px passes every assertion in your suite and is visible instantly in a diff image.

And what it cannot do: tell you whether the design is right. A golden test only knows whether today matches the day you last pressed --update-goldens. If the original was wrong, the golden locks the mistake in.

The API

matchesGoldenFile, which is what a golden test actually is

shown, not run
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('price card, light theme', (tester) async {
    // A golden should own its window. The default is 800x600 at dpr 3, which is
    // no real device, so most suites set the size they mean.
    tester.view.physicalSize = const Size(1080, 600);
    tester.view.devicePixelRatio = 3.0;
    addTearDown(tester.view.reset);

    await tester.pumpWidget(
      MaterialApp(
        theme: ThemeData.light(useMaterial3: true),
        home: const Scaffold(body: Center(child: PriceCard(pence: 1299))),
      ),
    );
    await tester.pumpAndSettle();

    // The comparison. The path is relative to the test file, and by convention
    // lives in test/goldens/.
    await expectLater(
      find.byType(PriceCard),
      matchesGoldenFile('goldens/price_card_light.png'),
    );
  });
}

// Write or rewrite the PNGs:
//   flutter test --update-goldens
// Review the diff like code. A golden update in a pull request with no visual
// change described is the review smell to watch for.

A golden test needs a checked-in PNG to compare against and a comparator that can write one. This build has neither, and inventing one would make the "ran and analysed" badge on this page a lie. What is verified below instead is the determinism the golden depends on.

What can be proven here

A golden test is only meaningful if rendering the same tree twice produces the same result. That part is testable without any image files, and it is worth seeing, because it shows exactly which numbers the test environment fixes for you and which it does not.

Layout determinism, which is the floor a golden stands on

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Widget card() => const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              SizedBox(key: Key('card'), width: 300, height: 120),
              Text('abcd', style: TextStyle(fontSize: 20)),
            ],
          ),
        ),
      ),
    );

void main() {
  testWidgets('the same tree lays out identically twice', (tester) async {
    // Every widget test runs in the same fixed window, whatever machine it is on.
    expect(tester.view.physicalSize / tester.view.devicePixelRatio, const Size(800, 600));

    await tester.pumpWidget(card());
    final firstCard = tester.getRect(find.byKey(const Key('card')));
    final firstText = tester.getSize(find.text('abcd'));

    // Tear the tree down completely and build it again.
    await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
    await tester.pumpWidget(card());

    expect(tester.getRect(find.byKey(const Key('card'))), firstCard);
    expect(tester.getSize(find.text('abcd')), firstText);

    // The box we asked for is exactly the box we got, so this number is safe to
    // write down in a test.
    expect(firstCard.width, 300.0);

    // The TEXT size is not. It comes from font metrics, and this panel
    // deliberately does not hard-code it - see the note below.
    expect(firstText.width, greaterThan(0));
  });
}

Result

This ran as a real widget test during the build, and passed.

Read the last two assertions together. The width we asked for is asserted exactly. The measured width of the text is asserted only to be positive, because it depends on the font the test environment supplied — and a golden PNG is a hard-coded assertion about every such number at once. That is the whole reason goldens are fragile: they promise things about the renderer that the rest of your suite is careful not to promise.

Why they break

  • Fonts. flutter test does not load the fonts in your pubspec.yaml. Text renders in whatever the test environment provides, so a golden taken on a machine with your brand font will not match one taken without it. This is the single commonest cause.
  • Platform. Skia and Impeller rasterise text and antialiased curves differently on macOS, Linux and Windows. A golden generated on a developer's Mac will usually fail on a Linux CI runner, by a few pixels along every edge.
  • Flutter version. Engine upgrades change rendering. A minor Flutter bump legitimately invalidates a whole golden directory, and that is not a bug.
  • Antialiasing and shadows. Rounded corners, gradients and elevation shadows are exactly the pixels that differ between renderers, and they are also on the outline of every card in your app.
  • Anything asynchronous. An image still decoding, a shimmer mid-cycle, a cursor blinking. If the frame you captured was not the settled one, the golden is a coin toss.

The tolerance argument, with numbers

The obvious fix for antialiasing noise is to allow a small percentage of pixels to differ. Do the arithmetic before you agree to it.

Any tolerance big enough to absorb antialiasing is big enough to hide a missing icon

ran and analysed
int pixels(int logicalW, int logicalH, num dpr) =>
    (logicalW * dpr).round() * (logicalH * dpr).round();

/// A one-pixel outline shift touches the perimeter of the shape, twice over:
/// the pixels it left and the pixels it arrived at.
int outlineShift(int logicalW, int logicalH, num dpr) =>
    2 * 2 * ((logicalW * dpr).round() + (logicalH * dpr).round());

int allowedBy(int total, double percent) => (total * percent / 100).floor();

void main() {
  final total = pixels(400, 200, 3);
  final shift = outlineShift(400, 200, 3);
  final icon = pixels(24, 24, 3);

  // The tolerance you need to stop the antialiasing noise failing your build.
  final toAbsorbNoise = 100 * shift / total;
  final allowed = allowedBy(total, toAbsorbNoise);

  print('a 400x200 card at dpr 3 is $total pixels');
  print('a one pixel outline shift moves $shift of them, which is '
      '${toAbsorbNoise.toStringAsFixed(2)}%');
  print('a missing 24x24 icon is only $icon pixels');

  test('the golden is 720,000 pixels', total, 720000);
  test('the noise you want forgiven is 7,200 pixels', shift, 7200);
  test('so the tolerance has to be at least 1 per cent', close(toAbsorbNoise, 1.0, 0.001), true);
  test('a missing 24x24 icon is smaller than that, at 5,184 pixels', icon, 5184);
  test('so the tolerance that absorbs the noise also hides the icon', icon <= allowed, true);
}

What it printed, when the build ran it

a 400x200 card at dpr 3 is 720000 pixels
a one pixel outline shift moves 7200 of them, which is 1.00%
a missing 24x24 icon is only 5184 pixels
ok   the golden is 720,000 pixels
ok   the noise you want forgiven is 7,200 pixels
ok   so the tolerance has to be at least 1 per cent
ok   a missing 24x24 icon is smaller than that, at 5,184 pixels
ok   so the tolerance that absorbs the noise also hides the icon

That is the argument to make out loud: a percentage tolerance does not distinguish "every edge is half a shade different" from "one component did not render". Both are a few thousand pixels. The better fixes are to remove the variance instead of forgiving it — load real fonts, pin the Flutter version, and generate goldens on the same platform CI uses.

Making them stable

flutter_test_config.dart is picked up automatically for every test in its directory and below. It is the right place to load fonts, because doing it per test file is how a suite ends up half deterministic.

The file that makes a golden suite deterministic

shown, not run
// test/flutter_test_config.dart - no import of this file anywhere; the test
// runner finds it by name and wraps every test in the directory tree.
import 'dart:async';
import 'dart:io';

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  TestWidgetsFlutterBinding.ensureInitialized();

  // Without this, every golden in the suite is a picture of the fallback font.
  final loader = FontLoader('Inter')
    ..addFont(File('fonts/Inter-Regular.ttf').readAsBytes().then(ByteData.sublistView))
    ..addFont(File('fonts/Inter-Bold.ttf').readAsBytes().then(ByteData.sublistView));
  await loader.load();

  // Shadows are one of the least portable things Skia draws. Turning them off
  // costs a little fidelity and removes a whole class of one-pixel failures.
  debugDisableShadows = true;

  await testMain();
}

It reads real font files from disk and has to be discovered by the test runner by filename, so it cannot be one panel in a batched run. Shown because the file existing at all is the fix for the commonest golden failure.

The CI shape that makes goldens usable rather than hated

shown, not run
# Goldens are generated on Linux and compared on Linux. Nothing else.
name: test
on: [push, pull_request]

jobs:
  unit-and-widget:
    runs-on: ubuntu-latest      # every developer OS is allowed; this one is not optional
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: 3.27.1   # pinned exactly. 'stable' re-generates your goldens for you.
          cache: true
      - run: flutter pub get
      - run: flutter analyze --fatal-infos
      - run: flutter test --exclude-tags golden

  goldens:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: 3.27.1
      - run: flutter pub get
      - run: flutter test --tags golden
      # On failure the runner writes test/failures/*.png: the golden, the actual
      # and the diff. Publish them or nobody can review the failure.
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: golden-failures
          path: '**/failures/*.png'

Configuration, not code. Nothing to execute.

The two rules that decide whether a team keeps its golden suite. Goldens are generated in one place only, and it is the same place they are checked — which in practice means a container, not a laptop. And they are tagged, so they can be excluded from the fast test run. A suite where flutter test fails on a developer's Mac for reasons that are not their fault gets --update-goldens'd into meaninglessness within a month.

What to golden, since the answer is not "everything". Design system components, at each theme and each text scale: they are small, stable, and shared by every screen. Any screen with a layout that has broken before. Empty, loading and error states, because nobody looks at those by hand. Not whole feature screens full of live data — those change for real reasons every sprint, and a test that fails every sprint teaches the team to press the update button without looking.

33 Finding jank, and proving you fixed it

Jank is one frame arriving late. That is the whole definition, and it is why the subject is arithmetic rather than opinion: you have a fixed budget per frame, you can find out what you spent, and you can say which thread you spent it on.

The budget

  • 60Hz — a frame every 16.67ms. Miss it and the previous frame is shown twice, which reads as a stutter.
  • 120Hz — 8.33ms. Most current flagships, and half the budget. Code that was comfortable on a 60Hz test device janks visibly on a newer phone.
  • The budget covers everything: your build, layout, paint, and then the raster work that turns the paint instructions into pixels.

And the number people get wrong in interviews: the budget is not per widget or per animation, it is per frame for the whole app, including anything else you decided to do on the UI thread during it.

The budget, and the same frame passing on one device and failing on another

ran and analysed
/// One frame, phase by phase, in milliseconds.
class Frame {
  const Frame({
    required this.build,
    required this.layout,
    required this.paint,
    required this.raster,
  });

  final double build, layout, paint, raster;

  /// The UI thread does build, layout and paint. Raster is a different thread.
  double get ui => build + layout + paint;
  double get total => ui + raster;

  bool fitsAt(int hz) => total <= 1000 / hz;
}

void main() {
  const comfortable = Frame(build: 1.5, layout: 1.0, paint: 1.0, raster: 3.0);
  const marginal = Frame(build: 4.0, layout: 2.5, paint: 2.0, raster: 6.0);

  print('60Hz budget:  ${(1000 / 60).toStringAsFixed(2)} ms');
  print('120Hz budget: ${(1000 / 120).toStringAsFixed(2)} ms');
  print('marginal frame: ui ${marginal.ui} + raster ${marginal.raster} = ${marginal.total} ms');

  test('a 60Hz frame budget is 16.67ms', close(1000 / 60, 16.667, 0.001), true);
  test('at 120Hz it is 8.33ms', close(1000 / 120, 8.333, 0.001), true);
  test('the comfortable frame fits at both rates', comfortable.fitsAt(120), true);
  test('the marginal one fits at 60Hz', marginal.fitsAt(60), true);
  test('and misses at 120Hz, which is the same code on a newer phone',
      marginal.fitsAt(120), false);
  test('its UI thread alone is already over the 120Hz budget', marginal.ui > 1000 / 120, true);
}

What it printed, when the build ran it

60Hz budget:  16.67 ms
120Hz budget: 8.33 ms
marginal frame: ui 8.5 + raster 6.0 = 14.5 ms
ok   a 60Hz frame budget is 16.67ms
ok   at 120Hz it is 8.33ms
ok   the comfortable frame fits at both rates
ok   the marginal one fits at 60Hz
ok   and misses at 120Hz, which is the same code on a newer phone
ok   its UI thread alone is already over the 120Hz budget

Two threads, two kinds of jank

This distinction is the thing a senior interview is checking for, because the fixes are completely different and people apply them to the wrong problem.

  • UI thread jank — your Dart is too slow. A build that sorts a list, decodes JSON, formats dates, or a setState that rebuilds a thousand widgets. Fix: do less work, or do it somewhere else. Lesson 12's isolate, lesson 17's narrower rebuild, a cached value instead of a computed one.
  • Raster thread jank — the GPU work per frame is too expensive. Large blurs, Opacity over a big subtree, saveLayer, shader compilation on the first frame of an animation, clipping with anti-aliasing. Fix: RepaintBoundary, cheaper effects, fewer layers. Doing less Dart work here changes nothing at all.

The consequence worth saying out loud: if the timeline shows the raster thread over budget, moving work to an isolate is wasted effort. And if the UI thread is over budget, a RepaintBoundary is wasted effort. Guessing costs a sprint.

Why one slow frame is worse than it sounds, and worse again at 120Hz

ran and analysed
/// Frames rendered during a scroll, and what a slow frame actually costs.
int framesDropped(double frameMs, int hz) {
  final budget = 1000 / hz;
  if (frameMs <= budget) return 0;
  return (frameMs / budget).ceil() - 1;
}

/// The number the team should be tracking: not the worst frame, the share of
/// frames that missed.
double jankPercent(List<double> frames, int hz) {
  final late = frames.where((f) => f > 1000 / hz).length;
  return late * 100 / frames.length;
}

void main() {
  // A ten second scroll at 60Hz is 600 frames. Ninety of them did a JSON decode
  // because the list built its items lazily and parsed on demand.
  final frames = <double>[
    for (var i = 0; i < 510; i++) 9.0,
    for (var i = 0; i < 90; i++) 34.0,
  ];

  print('a 34ms frame at 60Hz drops ${framesDropped(34, 60)} frame(s)');
  print('the same frame at 120Hz drops ${framesDropped(34, 120)}');
  print('jank over the scroll: ${jankPercent(frames, 60).toStringAsFixed(1)}%');

  test('a 34ms frame drops two frames at 60Hz', framesDropped(34, 60), 2);
  test('and four at 120Hz, so the newer phone looks worse', framesDropped(34, 120), 4);
  test('a 9ms frame drops none', framesDropped(9, 60), 0);
  test('fifteen per cent of the scroll janked', close(jankPercent(frames, 60), 15.0), true);
  test('a 16.6ms frame is inside the budget, just', framesDropped(16.6, 60), 0);
}

What it printed, when the build ran it

a 34ms frame at 60Hz drops 2 frame(s)
the same frame at 120Hz drops 4
jank over the scroll: 15.0%
ok   a 34ms frame drops two frames at 60Hz
ok   and four at 120Hz, so the newer phone looks worse
ok   a 9ms frame drops none
ok   fifteen per cent of the scroll janked
ok   a 16.6ms frame is inside the budget, just

Two things to take from those numbers. A frame that overruns by a factor of two costs you two frames, not one, because the next vsync has already gone. And the percentage is the metric to report, not the maximum — "fifteen per cent of frames during the feed scroll missed the budget, worst 34ms" is a sentence an engineering manager can act on. "It feels janky on my phone" is not.

RepaintBoundary, with the paints counted

Here is the raster-side fix, measured. A small animation sits next to a chart that is expensive to paint and never changes. Without a boundary between them, the animation dirties the layer they share, so the chart is repainted on every frame of the animation. With one, the chart's layer is kept and reused.

Six paints becomes one, and the code differs by one widget

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

int paints = 0;

/// Stands in for anything expensive to rasterise: a chart, a map, a blurred
/// header. shouldRepaint is false, and that is not enough on its own.
class Chart extends CustomPainter {
  const Chart();

  @override
  void paint(Canvas canvas, Size size) {
    paints++;
    canvas.drawRect(Offset.zero & size, Paint()..color = const Color(0xFF2244AA));
  }

  @override
  bool shouldRepaint(Chart oldDelegate) => false;
}

class Screen extends StatefulWidget {
  const Screen({super.key, required this.boundary});
  final bool boundary;

  @override
  State<Screen> createState() => _ScreenState();
}

class _ScreenState extends State<Screen> with SingleTickerProviderStateMixin {
  late final AnimationController slide =
      AnimationController(vsync: this, duration: const Duration(milliseconds: 500))..repeat();

  @override
  void dispose() {
    slide.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    const chart = CustomPaint(size: Size(200, 100), painter: Chart());
    return Column(
      children: [
        AnimatedBuilder(
          animation: slide,
          builder: (context, _) => Transform.translate(
            offset: Offset(slide.value * 50, 0),
            child: const SizedBox(width: 20, height: 20, child: ColoredBox(color: Colors.red)),
          ),
        ),
        if (widget.boundary) const RepaintBoundary(child: chart) else chart,
      ],
    );
  }
}

Future<void> pumpFive(WidgetTester tester) async {
  for (var i = 0; i < 5; i++) {
    await tester.pump(const Duration(milliseconds: 16));
  }
}

void main() {
  testWidgets('a RepaintBoundary stops the sibling repainting', (tester) async {
    paints = 0;
    await tester.pumpWidget(const MaterialApp(home: Screen(boundary: false)));
    expect(paints, 1, reason: 'the first frame has to paint it once');
    await pumpFive(tester);
    final without = paints;

    paints = 0;
    await tester.pumpWidget(const MaterialApp(home: Screen(boundary: true)));
    await pumpFive(tester);
    final withBoundary = paints;

    // without: 6   with: 1
    debugPrint('without: $without  with: $withBoundary');
    expect(without, 6, reason: 'one initial paint plus one per animated frame');
    expect(withBoundary, 1, reason: 'the layer was reused, so paint was never called again');
    expect(without - withBoundary, 5);

    await tester.pumpWidget(const MaterialApp(home: SizedBox()));
  });
}

Result

This ran as a real widget test during the build, and passed.

Do not sprinkle them. Every RepaintBoundary is a texture the GPU has to keep and composite, so a boundary around a cheap widget makes things slower. The rule: put one between something that changes every frame and something expensive that does not. Flutter already puts them where they usually pay — every item of a scrolling ListView, for instance — so the ones you add are for the cases it could not guess.

Finding it, rather than guessing

The DevTools timeline, in the order that narrows it down

shown, not run
// The workflow, in the order that actually narrows it down.
//
// 1. PROFILE MODE, ON A DEVICE. Debug builds are not indicative: assertions run,
//    the code is JIT-compiled and shader warm-up has not happened.
//
//      flutter run --profile
//
//    A debug-mode measurement is the commonest mistake in this whole subject.
//    Numbers from an emulator are not much better.
//
// 2. DEVTOOLS > PERFORMANCE. Turn on the frame chart and reproduce the jank.
//    Every bar is one frame, split into two colours:
//      blue  = UI thread   (your Dart: build, layout, paint)
//      green = raster      (the GPU work)
//    The colour of the tall bars tells you which half of this lesson applies.
//    Nothing else you do should precede this step.
//
// 3. FOR A TALL BLUE BAR. Expand the frame and read the timeline events.
//    'Build' dominating means too many widgets rebuilt: lesson 17.
//    A long custom event means your own code: move it off the frame, or off the
//    thread with an isolate (lesson 12).
//
// 4. FOR A TALL GREEN BAR. Switch on the rendering toggles:
//      Highlight repaints         - anything flashing that should be static
//                                   wants a RepaintBoundary
//      Highlight oversized images - a 4000px JPEG being drawn 200px wide
//      Track widget builds        - counts, live, per widget
//
// 5. WRITE THE NUMBER DOWN. Frames over budget, before and after. A performance
//    change with no before-and-after number is an opinion, and it will be
//    reverted by the next person who has a different one.
//
// In code, the same data is available without the UI:
//   SchedulerBinding.instance.addTimingsCallback((timings) {
//     for (final t in timings) {
//       // t.buildDuration, t.rasterDuration, t.totalSpan
//     }
//   });
// Ship that to analytics and you have jank as a production metric rather than a
// bug report.

It needs a physical device in profile mode and the DevTools UI. Neither exists in a headless test run, so this is the procedure rather than a panel that could be executed.

The answer that gets marked well when someone says "the feed is janky". Reproduce it in profile mode on the device it happens on. Look at the frame chart and say which thread. Then, and only then, choose the fix from the right list — and measure the same scroll again. Candidates who jump straight to const, RepaintBoundary or an isolate are answering a question nobody asked yet.

Two production causes worth naming. Shader compilation jank: the first frame of an animation or a page transition compiles a shader, which shows up as one very expensive frame at exactly the moment the user notices. And oversized images: decoding a 4000-pixel photo to draw it 200 pixels wide costs memory and raster time in one go, which cacheWidth, cacheHeight or ResizeImage fixes in a line. Lesson 34 has the memory half of that story.

34 Leaks: controllers, listeners and images

A Dart leak is almost never the garbage collector failing. It is something still holding a reference: a listener nobody removed, a subscription nobody cancelled, a ticker still running. The object cannot be collected because, from the runtime's point of view, it is still in use — and it is, by code that no longer has a screen to draw on.

The rule

Anything you construct in initState that holds a subscription, a ticker or a native handle must be released in dispose. Not "should be" — the framework will not do it for you, and nothing warns you at compile time.

  • AnimationController — holds a Ticker registered with the scheduler. Undisposed, it keeps requesting frames forever. Flutter does shout about this one in debug, which is why it is the leak people have met.
  • TextEditingController, ScrollController, PageController, TabController — all ChangeNotifiers. Undisposed, they retain every listener, and every listener retains whatever it closed over, which is usually your whole State.
  • FocusNode — retained by the focus manager as well as by you.
  • StreamSubscription — cancel(), not dispose(). A subscription to a broadcast stream that outlives its widget will keep delivering events into a dead callback.
  • Timer and Timer.periodic — cancel(). A periodic timer is the purest form of this bug: it fires forever, on a schedule, from a screen the user closed.
  • Anything you called addListener on — including a notifier you did not create. You do not own it, so you must not dispose it, but you do own the subscription, so you must remove it.

That last distinction is the one interviews probe. Owning a controller means disposing it. Listening to someone else's means removing your listener and nothing more. Dispose a controller you were handed and you break the widget that handed it to you.

The listener leak, and why the callback fires twelve times

ChangeNotifier keeps its listeners in a plain list. It does not de-duplicate. Add the same callback twelve times and you have twelve entries, so one notifyListeners() runs your code twelve times. Below is that list, reduced to the part that misbehaves.

The mechanism behind "why is my callback firing twelve times"

ran and analysed
/// ChangeNotifier, reduced to the part that leaks: a list of callbacks that
/// nothing removes on your behalf, and which does not de-duplicate.
class Notifier {
  final _listeners = <void Function()>[];

  int get listenerCount => _listeners.length;

  void addListener(void Function() fn) {
    _listeners.add(fn);
  }

  void removeListener(void Function() fn) {
    _listeners.remove(fn);
  }

  void notify() {
    // A copy, because a listener is allowed to unsubscribe while being notified.
    for (final fn in List.of(_listeners)) {
      fn();
    }
  }
}

/// A widget that subscribes every time it is built and never unsubscribes. In
/// real code this is an addListener in build(), or in didChangeDependencies
/// without the matching removal, or in initState on a widget whose key changed.
class Subscriber {
  Subscriber(this.source);

  final Notifier source;
  int notifications = 0;

  void onChanged() {
    notifications++;
  }

  void build() {
    source.addListener(onChanged);
  }
}

void main() {
  // The tidy case first, so the numbers below have something to be compared to.
  final settings = Notifier();
  var fired = 0;
  void once() {
    fired++;
  }

  settings.addListener(once);
  final afterAdd = settings.listenerCount;
  settings.notify();
  final whileSubscribed = fired;

  settings.removeListener(once);
  final afterRemove = settings.listenerCount;
  settings.notify();
  final afterRemoval = fired;

  // Now the leak. Twelve rebuilds, each adding the same callback again.
  final feed = Notifier();
  final row = Subscriber(feed);
  for (var i = 0; i < 12; i++) {
    row.build();
  }
  final leaked = feed.listenerCount;
  feed.notify();
  final calls = row.notifications;

  // A tear-off of the same method on the same object compares equal, so
  // removeListener finds it — and removes exactly one of the twelve.
  feed.removeListener(row.onChanged);
  final afterOneRemoval = feed.listenerCount;

  print('after twelve builds: $leaked listeners, $calls calls from one notify');
  print('after a single removeListener: $afterOneRemoval listeners');

  test('one listener added is one listener held', afterAdd, 1);
  test('a notification runs it exactly once', whileSubscribed, 1);
  test('removing it takes the count back to zero', afterRemove, 0);
  test('and the callback never runs again', afterRemoval, 1);
  test('twelve rebuilds that each subscribe leave twelve listeners', leaked, 12);
  test('so one notification runs one callback twelve times', calls, 12);
  test('one removeListener undoes one addListener, not the pile', afterOneRemoval, 11);
}

What it printed, when the build ran it

after twelve builds: 12 listeners, 12 calls from one notify
after a single removeListener: 11 listeners
ok   one listener added is one listener held
ok   a notification runs it exactly once
ok   removing it takes the count back to zero
ok   and the callback never runs again
ok   twelve rebuilds that each subscribe leave twelve listeners
ok   so one notification runs one callback twelve times
ok   one removeListener undoes one addListener, not the pile

Two things fall out of that last assertion. The count grows with the number of rebuilds, so the symptom scales with how busy the screen is — which is why it looks intermittent and why it is worse in production than on your machine. And the fix is not "call removeListener more"; it is to subscribe somewhere that runs once. initState runs once per State. build does not, and neither does didChangeDependencies.

Images: you pay for the pixels you decoded, not the ones you drew

A decoded bitmap is width * height * 4 bytes, uncompressed, in memory. Four bytes because it is 8-bit RGBA. The size of the Image widget on screen does not appear in that expression anywhere. A 3 MB JPEG on disk is not 3 MB in memory; it is however many pixels it contains, times four.

The decode cost of one avatar, and of fifty of them

ran and analysed
/// Bytes held by a decoded bitmap. cacheWidth is Flutter's decode-time resize:
/// the codec produces a smaller bitmap, so the big one never exists at all.
int decodedBytes(int width, int height, {int? cacheWidth}) {
  if (cacheWidth == null) return width * height * 4;
  final scaledHeight = cacheWidth * height ~/ width;
  return cacheWidth * scaledHeight * 4;
}

/// ImageCache defaults, as shipped.
const cacheObjectLimit = 1000;
const cacheByteLimit = 100 * 1024 * 1024;

void main() {
  // A photo straight off a modern phone camera, drawn as a 100 by 100 avatar.
  const w = 4032, h = 3024;
  final full = decodedBytes(w, h);

  // The avatar is 100 logical pixels on a device with a pixel ratio of 3, so the
  // most detail it can possibly show is 300 physical pixels across.
  final resized = decodedBytes(w, h, cacheWidth: 300);

  print('full decode:    $full bytes (${(full / 1048576).toStringAsFixed(1)} MiB)');
  print('cacheWidth 300: $resized bytes');
  print('the cache allows $cacheObjectLimit objects, but only '
      '${cacheByteLimit ~/ full} of these');

  test('a 4032 by 3024 photo decodes to 48,771,072 bytes', full, 48771072);
  test('which is 46.5 MiB, for one 100 by 100 avatar',
      close(full / 1048576, 46.512, 0.001), true);
  test('cacheWidth 300 decodes 300 by 225 instead, for 270,000 bytes', resized, 270000);
  test('the same avatar, 180 times smaller', full ~/ resized, 180);
  test('the 100 MiB cache holds two, so the 1000-object limit never gets a say',
      cacheByteLimit ~/ full, 2);
  test('fifty photos down a list want 2.27 GiB',
      close(50 * full / (1024 * 1024 * 1024), 2.2711, 0.001), true);
  test('the same fifty, resized on decode, want 12.9 MiB',
      close(50 * resized / 1048576, 12.8746, 0.001), true);
}

What it printed, when the build ran it

full decode:    48771072 bytes (46.5 MiB)
cacheWidth 300: 270000 bytes
the cache allows 1000 objects, but only 2 of these
ok   a 4032 by 3024 photo decodes to 48,771,072 bytes
ok   which is 46.5 MiB, for one 100 by 100 avatar
ok   cacheWidth 300 decodes 300 by 225 instead, for 270,000 bytes
ok   the same avatar, 180 times smaller
ok   the 100 MiB cache holds two, so the 1000-object limit never gets a say
ok   fifty photos down a list want 2.27 GiB
ok   the same fifty, resized on decode, want 12.9 MiB

What the numbers mean for a real list. ImageCache defaults to 1000 objects and 100 MiB. Two full-size camera photos fill the byte budget, so scrolling a feed of them means the cache thrashes: decode, evict, decode the same image again on the way back up. That is both the memory spike and the raster jank from lesson 33, from one cause. Worse, eviction only frees images the cache alone is holding — anything a live Image widget still references stays in memory regardless of the limit, so fifty on-screen at once is fifty decodes no policy will save you from.

The fix is one line at the point of decode: cacheWidth and cacheHeight on Image, or ResizeImage around any ImageProvider. Note that BoxFit, SizedBox and Transform.scale do none of this — they change what is painted, after the full bitmap already exists. Turn on "Highlight oversized images" in DevTools and the offenders invert themselves on screen.

Proving there is no leak

This is the part most candidates cannot answer. hasListeners on ChangeNotifier is @protected, so a subclass exposes it — and with that one line, "did the widget clean up after itself" becomes an assertion. Note that the test owns the notifier and the widget only borrows it, which is the ownership rule from earlier applied to the test itself.

The leak, proven present in one widget and absent in the other

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// hasListeners is @protected on ChangeNotifier. A one-line getter in a subclass
/// exposes it, and that is the whole trick behind a leak test you can run.
class Model extends ChangeNotifier {
  bool get isListenedTo => hasListeners;

  void bump() => notifyListeners();
}

/// Subscribes in initState, unsubscribes in dispose. The model is passed in, so
/// this widget does NOT dispose it — it only owns the subscription.
class Tidy extends StatefulWidget {
  const Tidy({super.key, required this.model});
  final Model model;

  @override
  State<Tidy> createState() => _TidyState();
}

class _TidyState extends State<Tidy> {
  int seen = 0;

  @override
  void initState() {
    super.initState();
    widget.model.addListener(onChanged);
  }

  @override
  void dispose() {
    widget.model.removeListener(onChanged);
    super.dispose();
  }

  void onChanged() => setState(() => seen++);

  @override
  Widget build(BuildContext context) => Text('tidy $seen');
}

/// The same widget with the dispose override deleted, and nothing else changed.
/// Nothing warns you, at compile time or at run time, until the test below asks.
class Leaky extends StatefulWidget {
  const Leaky({super.key, required this.model});
  final Model model;

  @override
  State<Leaky> createState() => _LeakyState();
}

class _LeakyState extends State<Leaky> {
  int seen = 0;

  @override
  void initState() {
    super.initState();
    widget.model.addListener(onChanged);
  }

  void onChanged() => setState(() => seen++);

  @override
  Widget build(BuildContext context) => Text('leaky $seen');
}

void main() {
  testWidgets('a State that removes its listener leaves nothing behind', (tester) async {
    final model = Model();
    addTearDown(model.dispose);
    expect(model.isListenedTo, isFalse, reason: 'nothing has subscribed yet');

    await tester.pumpWidget(MaterialApp(home: Tidy(model: model)));
    expect(model.isListenedTo, isTrue, reason: 'initState subscribed');

    model.bump();
    await tester.pump();
    expect(find.text('tidy 1'), findsOneWidget);

    // Replacing the tree disposes the State. This is navigating away, in one line.
    await tester.pumpWidget(const MaterialApp(home: SizedBox()));
    expect(model.isListenedTo, isFalse, reason: 'dispose unsubscribed: no leak');
  });

  testWidgets('the same widget without dispose keeps the listener, then crashes', (tester) async {
    final model = Model();
    addTearDown(model.dispose);

    await tester.pumpWidget(MaterialApp(home: Leaky(model: model)));
    expect(model.isListenedTo, isTrue);

    await tester.pumpWidget(const MaterialApp(home: SizedBox()));
    expect(model.isListenedTo, isTrue, reason: 'the State is gone; its callback is not');

    // The dead callback still runs. setState on a defunct State throws, and the
    // notifier reports it rather than rethrowing, so the test has to collect it.
    model.bump();
    final thrown = tester.takeException();
    expect(thrown, isA<FlutterError>());
    expect('$thrown', contains('after dispose'));
  });
}

Result

This ran as a real widget test during the build, and passed.

Say this rather than "we would check DevTools". A leak test is three lines: expose hasListeners, pump the widget, pump something else, assert the count came back to zero. It runs in milliseconds, it runs in CI, and it fails the moment somebody deletes a dispose in a refactor. The same shape works for a StreamController (hasListener), a FocusNode and anything else that can be asked whether it is still being watched.

mounted, and the context that died during the await

The second test above is a slower version of the commonest crash in Flutter code. An await is a gap in which anything can happen, including the user pressing back. When your code resumes, the State may be defunct and the BuildContext you captured before the gap may belong to an element that is no longer in the tree. mounted is the guard, and it has to be re-checked after every await, not once at the top.

The same method, before and after

shown, not run
// Wrong. Two awaits, three chances to be dead by the time the line runs.
Future<void> save() async {
  final saved = await api.save(draft);          // the user can press back here
  setState(() => status = saved);               // "setState() called after dispose()"
  final theme = Theme.of(context);              // and this context may be defunct
  ScaffoldMessenger.of(context).showSnackBar(   // ...so this throws too
    SnackBar(content: Text('Saved'), backgroundColor: theme.colorScheme.primary),
  );
}

// Right. Guard after every gap, and take what you need from the context BEFORE
// the await, while it is certainly still alive.
Future<void> save() async {
  final messenger = ScaffoldMessenger.of(context);   // captured while mounted
  final saved = await api.save(draft);
  if (!mounted) return;                              // the State may be gone
  setState(() => status = saved);
  await Future<void>.delayed(const Duration(milliseconds: 300));
  if (!mounted) return;                              // ...and again, after this one
  messenger.showSnackBar(const SnackBar(content: Text('Saved')));
}

// Notes worth carrying into an interview:
//
// * 'mounted' on State is not the same as 'context.mounted' on BuildContext. Both
//   exist; the second is what you use inside a helper that was handed a context
//   and has no State of its own.
//
// * The analyser has a lint for exactly this: use_build_context_synchronously.
//   Turn it on. It is off in a bare project and it catches real crashes.
//
// * A captured Navigator, ScaffoldMessenger or Theme is safe across an await
//   because you resolved it while the tree was alive. A captured BuildContext is
//   not. Resolve early, use late.
//
// * 'if (!mounted) return;' after an await that already failed is not a fix for a
//   leak. The subscription is still there. It only stops the crash.

It is the shape of two real methods rather than a self-contained program: it needs an api client, a draft, a Scaffold with a messenger above it and a user who can press back mid-request. The mechanism it describes — a callback surviving its State and calling setState — is proven by the widget test above.

Finding one, honestly

The tests above catch the leaks you thought to write a test for. For the ones you did not, the signature in DevTools is specific and worth memorising: navigate into a screen and back out several times, force a garbage collection, and watch whether the heap returns to where it started. A heap that steps up and never comes down is a leak. A heap that spikes and recovers is just a big allocation, which is a different problem.

  • flutter run --profile — the only mode worth measuring in. Debug builds keep extra references alive for the inspector, so debug-mode memory numbers are not evidence.
  • DevTools > Memory — the timeline chart for the shape, then "Snapshot" for what is actually on the heap. Do a snapshot before and after the navigate-in-and-out loop and use "Diff" between them: the classes with a non-zero delta are your suspects.
  • Retaining paths — the answer to "why is this still alive". Pick an instance and DevTools shows you the chain of references from a GC root to it. Reading that chain backwards names the object that forgot to let go, usually a _ChangeNotifierListener closure holding a State.
  • The leak_tracker package — Flutter's own dispose-tracking harness, and what the framework's own tests use. It fails a test when an object that should have been disposed was not, which turns this whole lesson into a build step.

The answer that gets marked well when someone says "the app gets slower the longer you use it". Name the mechanism first: something is retaining objects, so either a subscription outlived its widget or a cache has no bound. Then say how you would tell the two apart — the navigate-in-and-out heap shape distinguishes them in about thirty seconds. Then say what you would leave behind: a widget test asserting the notifier has no listeners after the screen is gone, so the fix cannot silently regress. Candidates who answer with "dispose your controllers" have described the fix without the diagnosis, and the diagnosis was the question.

35 Accessibility, tested rather than hoped for

Flutter builds a second tree next to the widget tree, describing the screen to a screen reader rather than to a rasteriser. Most accessibility work is making sure that tree says something true — and the useful part is that you can assert on it, which turns accessibility from an intention into a test that fails.

The semantics tree

Every frame, alongside layout and paint, the framework compiles a SemanticsNode tree and ships it to the platform: TalkBack on Android, VoiceOver on iOS, the browser's accessibility tree on web. It is much smaller than the widget tree, because only widgets that describe something contribute to it.

  • Contributes automatically — Text (its string becomes a label), Icon when given a semanticLabel, IconButton, TextField, Slider, Checkbox, and every ButtonStyleButton. These arrive with a label, a role and the actions they support.
  • Contributes nothing at all — Container, SizedBox, Padding, Row, Column, Opacity. They are layout, and layout has nothing to say.
  • The trap — GestureDetector on a Container produces a node with a tap action and no label. A screen reader announces it as an unnamed button, or in some traversal orders skips past it. Every hand-rolled card, chip and icon-in-a-box in a codebase is this.
  • The tools for fixing it — Semantics(label:, button:, ...) to add meaning, ExcludeSemantics to hide decoration, MergeSemantics to make a label and its switch read as one thing rather than two. Reach for the first far more often than the other two.

An interviewer asking about this is usually checking one thing: do you know that a tappable Container is invisible to a screen reader unless you say something about it. That is the whole gap between an app that technically works and one that can be used.

Contrast, computed rather than eyeballed

WCAG's contrast ratio is a formula, not a judgement, so there is nothing to argue about. Linearise each 8-bit channel, weight them by how much the eye responds to each, and divide the lighter result by the darker with a 0.05 flare term on both. The thresholds: 4.5:1 for normal text, 3:1 for large text, where large means at least 18pt, or at least 14pt bold.

Three greys that all look reasonable, and one of them passes

ran and analysed
import 'dart:math' as math;

/// One 8-bit channel, undone from the sRGB transfer curve back to light.
double linearise(int c) {
  final s = c / 255;
  return s <= 0.03928 ? s / 12.92 : math.pow((s + 0.055) / 1.055, 2.4).toDouble();
}

/// Relative luminance. The green weight is more than three times the red one
/// because that is roughly how the eye divides its attention.
double luminance(int rgb) =>
    0.2126 * linearise((rgb >> 16) & 0xFF) +
    0.7152 * linearise((rgb >> 8) & 0xFF) +
    0.0722 * linearise(rgb & 0xFF);

/// Symmetric by construction: the brighter of the two always goes on top.
double contrast(int a, int b) {
  final la = luminance(a), lb = luminance(b);
  final brighter = la > lb ? la : lb;
  final darker = la > lb ? lb : la;
  return (brighter + 0.05) / (darker + 0.05);
}

bool passesBodyText(double ratio) => ratio >= 4.5;
bool passesLargeText(double ratio) => ratio >= 3.0;

void main() {
  const white = 0xFFFFFF, black = 0x000000;
  // Three greys on white. Every one of them has shipped as "secondary text".
  const grey999 = 0x999999, grey8A = 0x8A8A8A, grey76 = 0x767676;

  final onWhite999 = contrast(grey999, white);
  final onWhite8A = contrast(grey8A, white);
  final onWhite76 = contrast(grey76, white);

  print('#999999 on white: ${onWhite999.toStringAsFixed(2)}:1');
  print('#8A8A8A on white: ${onWhite8A.toStringAsFixed(2)}:1');
  print('#767676 on white: ${onWhite76.toStringAsFixed(2)}:1');

  test('black on white is 21 to 1, the highest ratio there is',
      close(contrast(black, white), 21.0, 1e-9), true);
  test('swapping foreground and background gives the same number',
      close(contrast(white, black), contrast(black, white), 1e-12), true);
  test('a colour against itself is 1 to 1', close(contrast(grey999, grey999), 1.0, 1e-12), true);
  test('the #999999 everybody reaches for is 2.85 to 1 on white',
      close(onWhite999, 2.849, 0.005), true);
  test('so it fails body text, and fails the large-text threshold too',
      [passesBodyText(onWhite999), passesLargeText(onWhite999)], [false, false]);
  test('#8A8A8A is 3.45, which passes for a heading and fails for a paragraph',
      [passesLargeText(onWhite8A), passesBodyText(onWhite8A)], [true, false]);
  test('#767676 is 4.54, about the lightest grey on white that passes',
      close(onWhite76, 4.542, 0.005), true);
}

What it printed, when the build ran it

#999999 on white: 2.85:1
#8A8A8A on white: 3.45:1
#767676 on white: 4.54:1
ok   black on white is 21 to 1, the highest ratio there is
ok   swapping foreground and background gives the same number
ok   a colour against itself is 1 to 1
ok   the #999999 everybody reaches for is 2.85 to 1 on white
ok   so it fails body text, and fails the large-text threshold too
ok   #8A8A8A is 3.45, which passes for a heading and fails for a paragraph
ok   #767676 is 4.54, about the lightest grey on white that passes

The sixth assertion is the one worth carrying around. #8A8A8A is legal on a 20pt heading and illegal on the 14pt caption underneath it, so "our brand grey" is not a decision you can make once — it is a decision per text style. And the fifth kills the usual defence: #999999 does not scrape past on large text either, it fails at 2.85 against a threshold of 3. If your design system has a "muted" colour, compute it now.

Tap targets and text scale

Two pieces of arithmetic, and between them they account for most of the real accessibility bugs in shipped Flutter apps. The minimum touch target is 48x48 logical pixels on Android and 44x44 on iOS. A 24x24 icon does not reach either on its own. And text scale is a system setting the user has already changed before they open your app: Android goes to 2.0, iOS accessibility sizes go past 3.0. A row with a hard-coded height does not survive that.

Where the 48 pixels come from, and where the 40 pixel row breaks

ran and analysed
/// Platform minimums for a touch target, in logical pixels.
const androidMinimum = 48.0;
const iosMinimum = 44.0;

/// Padding needed on each side of a square control to reach a minimum.
double padToReach(double side, double minimum) =>
    side >= minimum ? 0.0 : (minimum - side) / 2;

/// A list row with a hard-coded height and one line of text in it. This shape is
/// in every codebase, and it is the bug.
class FixedRow {
  const FixedRow({required this.height, required this.fontSize, required this.padding});

  final double height, fontSize, padding;

  /// A line box is the font size times the line-height multiplier.
  static const lineHeight = 1.2;

  /// MediaQuery.textScalerOf(context).scale(fontSize) is where this comes from at
  /// run time. TextScaler.linear(2.0) is how a test sets it.
  double contentAt(double textScale) => fontSize * textScale * lineHeight + padding * 2;

  /// Positive means pixels that do not fit: a debug overflow stripe, and clipped
  /// or ellipsised text in release.
  double overflowAt(double textScale) => contentAt(textScale) - height;

  /// The largest text scale this row can survive.
  double get largestScaleThatFits => (height - padding * 2) / (fontSize * lineHeight);
}

void main() {
  // A 40 pixel row with a 14pt label and 8 pixels of padding above and below.
  const row = FixedRow(height: 40, fontSize: 14, padding: 8);

  print('a 24px icon needs ${padToReach(24, androidMinimum)}px each side on Android');
  print('at text scale 1.0 the row has ${(-row.overflowAt(1.0)).toStringAsFixed(1)}px spare');
  print('at text scale 2.0 it overflows by ${row.overflowAt(2.0).toStringAsFixed(1)}px');
  print('it clips above ${row.largestScaleThatFits.toStringAsFixed(2)}x');

  test('a 24 pixel icon needs 12 pixels of padding on each side for Android',
      padToReach(24, androidMinimum), 12.0);
  test('and 10 to reach the 44 pixel iOS minimum', padToReach(24, iosMinimum), 10.0);
  test('something already 48 across needs none', padToReach(48, androidMinimum), 0.0);
  test('a 14pt label is 28pt once the user has doubled the text size', 14 * 2.0, 28.0);
  test('the 40 pixel row has 7.2 pixels of slack at the default scale',
      close(row.overflowAt(1.0), -7.2, 1e-9), true);
  test('and overflows by 9.6 pixels at a scale of two',
      close(row.overflowAt(2.0), 9.6, 1e-9), true);
  test('it starts clipping above 1.43, which the Android settings app passes easily',
      close(row.largestScaleThatFits, 1.4286, 0.0001), true);
}

What it printed, when the build ran it

a 24px icon needs 12.0px each side on Android
at text scale 1.0 the row has 7.2px spare
at text scale 2.0 it overflows by 9.6px
it clips above 1.43x
ok   a 24 pixel icon needs 12 pixels of padding on each side for Android
ok   and 10 to reach the 44 pixel iOS minimum
ok   something already 48 across needs none
ok   a 14pt label is 28pt once the user has doubled the text size
ok   the 40 pixel row has 7.2 pixels of slack at the default scale
ok   and overflows by 9.6 pixels at a scale of two
ok   it starts clipping above 1.43, which the Android settings app passes easily

Read the last assertion again. The row breaks at 1.43, and the first stop past "default" in the Android display settings is 1.15, then 1.3, then 1.5. So a perfectly ordinary user, not an accessibility user, sees your list clipped. The fixes are boring: minHeight instead of height, or IntrinsicHeight, or just letting the content size itself. What you must not do is cap the scale with MediaQuery — that is overriding a setting the user chose deliberately, and on iOS it is a review risk.

The matchers that make this a test rather than a claim

flutter_test ships accessibility guidelines as real matchers. They walk the semantics tree, and textContrastGuideline goes further and reads the rendered pixels, computing the same formula as the first panel against what was actually painted. They need semantics switched on, which tester.ensureSemantics() does.

Four guidelines passing, then the tap target one caught failing on purpose

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// A screen built to pass. Nothing exotic: explicit sizes, explicit colours, and a
/// label on the icon so it is not an anonymous button.
class OrderCard extends StatelessWidget {
  const OrderCard({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'Order 4821',
              style: TextStyle(fontSize: 18, color: Color(0xFF1A1A1A)),
            ),
            // 48 by 48, explicitly, because a 24 pixel icon is not a tap target.
            SizedBox(
              width: 48,
              height: 48,
              child: IconButton(
                onPressed: () {},
                icon: const Icon(
                  Icons.close,
                  size: 24,
                  color: Color(0xFF1A1A1A),
                  semanticLabel: 'Cancel order',
                ),
              ),
            ),
            ElevatedButton(
              style: ElevatedButton.styleFrom(
                backgroundColor: const Color(0xFF00468B),
                foregroundColor: Colors.white,
              ),
              onPressed: () {},
              child: const Text('Confirm'),
            ),
          ],
        ),
      ),
    );
  }
}

/// The trap from the top of the lesson: a tappable box, 24 by 24, with nothing
/// said about it. It renders fine and reads as nothing.
class HandRolled extends StatelessWidget {
  const HandRolled({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Center(
        child: GestureDetector(
          onTap: () {},
          child: const SizedBox(
            width: 24,
            height: 24,
            child: ColoredBox(color: Color(0xFF00468B)),
          ),
        ),
      ),
    );
  }
}

void main() {
  testWidgets('a screen that satisfies all four guidelines Flutter can check', (tester) async {
    final handle = tester.ensureSemantics();
    await tester.pumpWidget(const MaterialApp(home: OrderCard()));

    await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
    await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
    await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
    await expectLater(tester, meetsGuideline(textContrastGuideline));

    // The semantics tree, asserted directly. This is what TalkBack would read.
    expect(find.bySemanticsLabel('Cancel order'), findsOneWidget);
    expect(find.bySemanticsLabel('Confirm'), findsOneWidget);
    expect(find.bySemanticsLabel('Order 4821'), findsOneWidget);
    expect(find.bySemanticsLabel('Delete order'), findsNothing);

    handle.dispose();
  });

  testWidgets('and the matcher really does fail, which is the part worth checking', (tester) async {
    final handle = tester.ensureSemantics();
    await tester.pumpWidget(const MaterialApp(home: HandRolled()));

    Object? failure;
    try {
      await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
    } catch (error) {
      failure = error;
    }
    expect(failure, isA<TestFailure>(), reason: '24 by 24 is not a 48 by 48 target');
    expect('$failure', contains('48.0'));

    // It is also unlabelled, so a screen reader has nothing to announce.
    expect(find.bySemanticsLabel('Cancel order'), findsNothing);

    handle.dispose();
  });
}

Result

This ran as a real widget test during the build, and passed.

Two details in that test earn their keep. semanticLabel on the Icon is what gives the IconButton a name — a tooltip does a similar job and is worth adding for sighted users, but the label is the part the matcher and the screen reader both read. And the findsNothing for 'Delete order' is there for the reason lesson 31 gave: a negative semantics assertion is only meaningful next to a positive one.

This is the answer that separates the two candidates. "We added semantic labels" is an activity. "Our widget tests assert androidTapTargetGuideline and textContrastGuideline, and they run on every pull request" is a property of the codebase that survives the next person who does not know about any of this. Accessibility is one of the few quality attributes with an off-the-shelf executable definition, so claiming it without running it is a choice.

What the matchers do not cover. They cannot tell you whether a label reads sensibly out loud, whether the traversal order makes sense, whether a live region announces at the right moment, or whether your custom slider is operable at all without sight. textContrastGuideline also has to sample rendered pixels, so it is conservative about gradients and images behind text and will occasionally disagree with your design tool. Run them in CI for the floor, then spend twenty minutes with TalkBack or VoiceOver on your own app for everything above it. The second part is the one nobody does and the one that finds the real problems.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.