From Acceptance Criteria to Flutter Widget Tests: Making "Done" Mean Proven / Nov 10, 2026

6 min readBy Emmanuel Akinfulubi
From Acceptance Criteria to Flutter Widget Tests: Making "Done" Mean Proven

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 no onPressed is 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(), not pumpAndSettle(). A CircularProgressIndicator keeps animating, so the frame never settles. One pump() 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!
IDTestResult
1.1checkout shows the order totalPass
1.2tapping Pay disables it, shows loaderPass
1.3a declined card shows the error and re-enables PayPass

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:

  1. Review the tests before the code. Is every ID from requirements.md there?
  2. Check the expects match the SHALL, not how the widget happens to be built. expect(pay.onPressed, isNull) checks "disabled". Checking for a private _paying flag would test the implementation, not the requirement.
  3. 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

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.