skip to content

In Flutter's flutter_test, how do you write a widget test proving a login form shows its validation messages when submitted empty?

level: juniorimportance: must knowfreq 72%

answer

  1. testWidgets hands you a WidgetTester
  2. pumpWidget inside a MaterialApp
  3. find.byKey, find.text
  4. tap does not rebuild by itself
  5. findsOneWidget vs findsNothing

basics

~10 s

Call 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 s

A 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 lines
dart
import '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

for a junior

Recall the skeleton: testWidgets, pumpWidget in a MaterialApp, a finder, tap or enterText, pump, then expect with findsOneWidget or findsNothing.

for a middle

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.

for a senior

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.

for a principal

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