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.
/// 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.
/// 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.
/// 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.
/// 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.
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
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 byduration, 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.
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 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.
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.
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.
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
Textexists. 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
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.
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 testdoes not load the fonts in yourpubspec.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.
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.
// 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.
# 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.
/// 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
buildthat sorts a list, decodes JSON, formats dates, or asetStatethat 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,
Opacityover 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.
/// 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.
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 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
Tickerregistered 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 wholeState. - FocusNode — retained by the focus manager as well as by you.
- StreamSubscription —
cancel(), notdispose(). 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
addListeneron — 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.
/// 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.
/// 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.
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.
// 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
_ChangeNotifierListenerclosure holding aState. - The
leak_trackerpackage — 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),Iconwhen given asemanticLabel,IconButton,TextField,Slider,Checkbox, and everyButtonStyleButton. 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 —
GestureDetectoron aContainerproduces 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,ExcludeSemanticsto hide decoration,MergeSemanticsto 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.
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.
/// 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.
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.