In Flutter's flutter_test, how do you write a widget test proving a login form shows its validation messages when submitted empty?
answer
- testWidgets hands you a WidgetTester
- pumpWidget inside a MaterialApp
- find.byKey, find.text
- tap does not rebuild by itself
- findsOneWidget vs findsNothing
basics
~10 sCall testWidgets, build the form with tester.pumpWidget inside a MaterialApp, tap the submit button through a finder, call tester.pump() so the rebuild happens, then expect find.text('Email is required') to match findsOneWidget.
solid answer
~30 sA widget test is a `testWidgets('...', (WidgetTester tester) async { ... })` body. I build the screen with `await tester.pumpWidget(const MaterialApp(home: Scaffold(body: LoginForm())))` - the `MaterialApp` supplies the `Material`, `Directionality` and theme a `TextFormField` needs. Then I act and assert: `await tester.tap(find.byKey(const Key('login-submit')))`, `await tester.pump()` because a tap only dispatches pointer events and the `setState` it triggers waits for the next frame, and finally `expect(find.text('Email is required'), findsOneWidget)`. For the happy path I type with `await tester.enterText(find.byKey(const Key('login-email')), '[email protected]')`, pump, and expect the message to be `findsNothing`. Every `tester` call returns a `Future` and must be awaited.
code
dart · 30 linesimport 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/login_form.dart';
void main() {
testWidgets('empty submit shows both validation messages', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: LoginForm())),
);
await tester.tap(find.byKey(const Key('login-submit')));
await tester.pump();
expect(find.text('Email is required'), findsOneWidget);
expect(find.text('Password is required'), findsOneWidget);
});
testWidgets('a valid email clears the email message', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: LoginForm())),
);
await tester.enterText(find.byKey(const Key('login-email')), '[email protected]');
await tester.tap(find.byKey(const Key('login-submit')));
await tester.pump();
expect(find.text('Email is required'), findsNothing);
expect(find.text('Password is required'), findsOneWidget);
});
}go deeper
Recall the skeleton: testWidgets, pumpWidget in a MaterialApp, a finder, tap or enterText, pump, then expect with findsOneWidget or findsNothing.
Explain why a pump is needed after a tap - the action only dispatches events and marks elements dirty - and why every tester call must be awaited.
Show how you keep form tests stable: keys on controls, text only for user-visible messages, injected callbacks instead of reaching into State, one behaviour per test.
Frame widget tests as the cheapest place to pin form behaviour, and argue which form rules deserve a widget test versus a plain validator unit test.
## What a widget test is A **widget test** runs a piece of Flutter UI inside the Dart VM on your machine, without a device or an emulator. The `flutter_test` package provides the `testWidgets` function, which registers a test and hands its body a **`WidgetTester`** - the object you use to build widgets, find them, interact with them and advance time. Under the hood `testWidgets` initialises a test binding (`TestWidgetsFlutterBinding`) that replaces the real engine with a fake one: frames are produced on demand, time is simulated, and the default test surface is **800 x 600 logical pixels**. A login form's validation is the textbook target: it is pure UI logic, it depends on user input, and a regression in it is exactly the kind of thing a unit test of a validator function would miss (for example, the validator is correct but nobody wired it to the `TextFormField`). ## The four steps: build, find, act, assert 1. **Build** - `await tester.pumpWidget(widget)` attaches the widget as the root of the tree and renders one frame. Wrap the screen in a `MaterialApp` (and usually a `Scaffold`): a `TextFormField` throws "No Material widget found" without a `Material` ancestor, and text needs a `Directionality`. 2. **Find** - a **finder** describes how to locate widgets in the current tree. The global `find` object offers `find.byKey`, `find.byType`, `find.text`, `find.widgetWithText`, `find.byIcon` and more. 3. **Act** - `tester.tap(finder)`, `tester.enterText(finder, text)` and `tester.drag(finder, offset)` simulate the user. 4. **Assert** - `expect(finder, matcher)` with a count matcher such as `findsOneWidget`. ## Why a pump follows every action `tester.tap` synthesises a pointer-down and pointer-up at the centre of the target. The button's `onPressed` runs, your code calls `formKey.currentState!.validate()`, and the `FormField`s mark themselves dirty - but **nothing is rebuilt until the next frame**. In a widget test, frames only happen when you ask for one, so the assertion must come after `await tester.pump()`. Forgetting that pump is the single most common reason a first widget test fails with "Found 0 widgets" for a message that clearly appears in the running app. `tester.enterText` is slightly different: it focuses the field (the finder must be, or contain, an `EditableText`, which `TextField` and `TextFormField` do) and replaces its content as if typed on the soft keyboard. A pump afterwards is still needed before asserting on anything that the new text causes to rebuild. ## The count matchers | Matcher | Passes when the finder locates | |---|---| | `findsNothing` | zero widgets | | `findsOneWidget` / `findsOne` | exactly one widget | | `findsWidgets` / `findsAny` | one or more | | `findsNWidgets(n)` / `findsExactly(n)` | exactly n | | `findsAtLeastNWidgets(n)` / `findsAtLeast(n)` | n or more | The shorter names (`findsOne`, `findsAny`, `findsExactly`, `findsAtLeast`) are the newer spellings; both sets exist in current `flutter_test`. Asserting `findsNothing` for the error text on the happy path is as important as `findsOneWidget` on the empty submit - it proves the message is conditional. ## Picking stable finders for a form - Give the fields and the submit button explicit `Key`s (`const Key('login-email')`) and find them with `find.byKey`; labels and copy change more often than keys. - Use `find.text` for the **thing the user reads** - the validation message itself - because that is the behaviour under test. - Remember that `find.text` also matches an `EditableText` whose controller holds that text, so a field containing the same string as a message will be counted. ## Awaiting everything `pumpWidget`, `pump`, `tap`, `enterText` and `drag` all return a `Future`. A missing `await` lets two tester operations overlap, and `flutter_test` detects it with a "Guarded function conflict" error that tells you to use `await` with all Future-returning test APIs. It is a common first-test mistake and a quick one to recognise. ## What this test does not cover It proves the form's wiring and messages. How the authentication call is replaced by a fake belongs to mocking; pixel-exact appearance belongs to golden tests; the same flow on a real device belongs to an `integration_test` run. Keeping the widget test to one screen and one behaviour is what keeps it fast and deterministic.
- Why wrap the form in MaterialApp rather than pumping LoginForm alone?`pumpWidget` only wraps the widget in a `View`. A `TextFormField` needs a `Material` ancestor, a `Directionality`, localizations and a theme, all of which `MaterialApp` provides; without them the test fails with errors such as "No Material widget found" before it asserts anything.
- How would you assert that the submit callback was not called when validation fails?Inject the callback as a constructor parameter, pass a closure that increments a counter or records the call, tap submit with empty fields, pump, and `expect(calls, 0)`. That checks behaviour without reaching into the form's `State`.
- What does find.text match besides Text widgets?It also matches `Text.rich` by its plain text and, for an `EditableText`, compares against the controller's current value - so after `enterText`, `find.text('[email protected]')` finds the field itself.
saying these in an interview costs you the question
- Asserting right after tester.tap without a pump and expecting the message
- Pumping LoginForm with no MaterialApp and blaming the form for the crash
- Dropping await on tester calls because the test seemed to pass
- Finding the submit button by its label copy when a Key is available
- Checking only the error case and never asserting findsNothing on valid input