From Acceptance Criteria to Flutter Widget Tests: Making "Done" Mean Proven / Nov 10, 2026
Part of the material from my FlutterBytes Conference 2026 session on spec-driven development
in Lagos. The full walkthrough is in
Specs, Not Prompts. The code here is illustrative.
CheckoutScreen and all three tests were run with flutter analyze (no issues) and
flutter test (all 3 passed) on Flutter 3.47.1.
Done means proven
When an AI agent writes your Flutter code, "done" can't mean "looks fine on my emulator". It means proven.
The way to get there is one rule: every acceptance criterion becomes one test.
This post follows a single requirement all the way through, from a line in requirements.md to a green test run.
The requirement
Here's the requirements.md excerpt:
1.1 WHEN checkout opens THE APP SHALL show the order total.
1.2 WHEN the user taps Pay THE APP SHALL disable Pay and show a loader.
1.3 IF the payment fails THEN THE APP SHALL show the error and re-enable Pay.We'll follow 1.2.
The rule: same ID in both places
Requirement 1.2 gets exactly one widget test, and the test name starts with its ID:
testWidgets('1.2 tapping Pay disables it, shows loader', ...)Same ID in both places. Search your repo for 1.2 and you find the promise and the proof.
That sounds small. In practice it means:
- When a test fails, the output tells you which requirement broke.
- When you review the agent's tests, you can check every ID from requirements.md has one.
- When a requirement changes, you know exactly which test changes with it.
The test: WHEN becomes a tap, SHALL becomes an expect
Here's the full test for 1.2:
testWidgets('1.2 tapping Pay disables it, shows loader',
(tester) async {
final payment = Completer<void>(); // never completes
await tester.pumpWidget(MaterialApp(
home: CheckoutScreen(pay: () => payment.future),
));
await tester.tap(find.byKey(const Key('pay'))); // WHEN
await tester.pump();
final pay = tester.widget<FilledButton>(
find.byKey(const Key('pay')));
expect(pay.onPressed, isNull); // SHALL
expect(find.byType(CircularProgressIndicator), // SHALL
findsOneWidget);
});Read it next to the requirement:
- WHEN the user taps Pay is
tester.tap(find.byKey(const Key('pay'))). - THE APP SHALL disable Pay is
expect(pay.onPressed, isNull). A Material button with noonPressedis disabled. - and show a loader is
expect(find.byType(CircularProgressIndicator), findsOneWidget).
A few details that matter for loading states:
- The payment never completes. A
Completer<void>that's never completed keeps the payment "in flight", so the test can see the loading state. No network, no timers. pump(), notpumpAndSettle(). ACircularProgressIndicatorkeeps animating, so the frame never settles. Onepump()renders the loading state.- Find the button by key. Once tapped, the "Pay" label is replaced by the spinner, so finding by text would miss it.
The screen under test
Here's a minimal CheckoutScreen that satisfies 1.1 to 1.3 (lib/checkout_screen.dart,
illustrative):
import 'package:flutter/material.dart';
/// Illustrative: the smallest screen that satisfies requirements 1.1 to 1.3.
class CheckoutScreen extends StatefulWidget {
const CheckoutScreen({
super.key,
required this.pay,
this.totalLabel = 'Total: ₦12,500',
});
/// Runs the payment. Throws if the card is declined.
final Future<void> Function() pay;
final String totalLabel;
@override
State<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends State<CheckoutScreen> {
bool _paying = false;
String? _error;
Future<void> _onPay() async {
setState(() {
_paying = true;
_error = null;
});
try {
await widget.pay();
} catch (_) {
if (mounted) setState(() => _error = 'Card declined');
} finally {
if (mounted) setState(() => _paying = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Text(widget.totalLabel, key: const Key('total')), // 1.1
if (_error != null) Text(_error!, key: const Key('error')), // 1.3
FilledButton(
key: const Key('pay'),
onPressed: _paying ? null : _onPay, // 1.2: null = disabled
child: _paying
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Pay'),
),
],
),
);
}
}The payment is passed in as pay, so tests control it. That one design decision is what makes
every requirement testable without a real payment provider.
Green: every requirement has a test
Here's the full test file, one test per requirement (test/checkout_test.dart):
import 'dart:async';
import 'package:checkout_demo/checkout_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('1.1 checkout shows the order total', (tester) async {
await tester.pumpWidget(MaterialApp(
home: CheckoutScreen(pay: () async {}),
));
expect(find.byKey(const Key('total')), findsOneWidget);
});
testWidgets('1.2 tapping Pay disables it, shows loader',
(tester) async {
final payment = Completer<void>(); // never completes
await tester.pumpWidget(MaterialApp(
home: CheckoutScreen(pay: () => payment.future),
));
await tester.tap(find.byKey(const Key('pay')));
await tester.pump();
final pay = tester.widget<FilledButton>(
find.byKey(const Key('pay')));
expect(pay.onPressed, isNull);
expect(find.byType(CircularProgressIndicator),
findsOneWidget);
});
testWidgets('1.3 a declined card shows the error and re-enables Pay',
(tester) async {
await tester.pumpWidget(MaterialApp(
home: CheckoutScreen(pay: () async => throw Exception('declined')),
));
await tester.tap(find.byKey(const Key('pay')));
await tester.pump();
expect(find.text('Card declined'), findsOneWidget);
final pay = tester.widget<FilledButton>(
find.byKey(const Key('pay')));
expect(pay.onPressed, isNotNull);
});
}$ flutter test
00:01 +3: All tests passed!| ID | Test | Result |
|---|---|---|
| 1.1 | checkout shows the order total | Pass |
| 1.2 | tapping Pay disables it, shows loader | Pass |
| 1.3 | a declined card shows the error and re-enables Pay | Pass |
Swap checkout_demo for your package name if you try it.
Ask the agent for tests first
The order matters. Ask for the tests before any code:
Read requirements.md. For each acceptance criterion, write one widget
test in test/checkout_test.dart. Start each test name with its ID.
Don't change anything in lib/. Run flutter test and tell me which
ones fail.They should fail. That's the point: now "done" has a finish line. The agent's job from here is
to make them pass by changing lib/.
The rule for your agent's instructions
Don't edit a test to make it pass.
Put that in your agent's rules file. Then follow three steps when you review:
- Review the tests before the code. Is every ID from requirements.md there?
- Check the expects match the SHALL, not how the widget happens to be built.
expect(pay.onPressed, isNull)checks "disabled". Checking for a private_payingflag would test the implementation, not the requirement. - Then let the agent write
lib/until it's green. Tests are read-only.
Without that rule, an agent that can't make a test pass has an easy way out: change the test. The test goes green and the requirement quietly stops being checked.
Save this for the next time an agent says "done".
Sources
- Flutter docs: An introduction to widget testing:
testWidgets,WidgetTester,pumpWidgetand finders. - Mavin, A., Wilkinson, P., Harwood, A., Novak, M. (2009). "Easy Approach to Requirements Syntax (EARS)". RE'09. doi:10.1109/RE.2009.9
Frequently asked questions
- How do I turn an acceptance criterion into a Flutter widget test?
- Give each criterion exactly one testWidgets test whose name starts with the criterion's ID. The WHEN part becomes the action, such as tester.tap, and each SHALL becomes an expect on what the user can see.
- How do I test a loading state in Flutter?
- Pass the async work into the widget, then use a Completer that you never complete, so the work stays in flight. Tap, call pump once rather than pumpAndSettle, and assert on the disabled button and the progress indicator.
- How do I stop an AI agent from changing tests to make them pass?
- Put the rule in the agent's instructions: don't edit a test to make it pass. Ask for the tests first, review them before any code, and treat them as read-only while the agent changes lib/ until they go green.