In a Flutter widget test, why can tester.tap(find.text('Sign in')) throw or print a hit-test warning, and how do you choose the finder?
answer
- a tap needs exactly one match
- title and button share the text
- centre off the 800 x 600 surface
- warnIfMissed defaults to true
- byKey, byType, text, widgetWithText
basics
~20 stap needs a finder that matches exactly one widget and a centre that actually receives the hit. Duplicated text throws an ambiguity error; an off-screen or covered button prints a warning. Prefer find.byKey or find.widgetWithText, and scroll it into view first.
solid answer
~40 s`tester.tap` evaluates the finder, throws if it matches zero widgets ("could not find any matching widgets") or several ("ambiguously found multiple matching widgets"), and taps the target's centre. With `warnIfMissed` true by default, it hit-tests that point and prints a warning - or throws if `WidgetController.hitTestWarningShouldBeFatal` is set - when the widget would not receive it: off the 800 x 600 test surface, under a dialog, or behind an `IgnorePointer`. On a login screen, 'Sign in' is often both the `AppBar` title and the button label, so `find.text` matches two. I target controls with `find.byKey(const Key('login-submit'))`, or `find.widgetWithText(FilledButton, 'Sign in')`, call `tester.ensureVisible` or `scrollUntilVisible` for a button below the fold, and keep `find.text` for asserting what the user reads.
code
dart · 14 linestestWidgets('submit below the fold is scrolled into view before tapping', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: LoginScreen()));
// Ambiguous: the AppBar title and the button both read 'Sign in'.
expect(find.text('Sign in'), findsNWidgets(2));
final Finder submit = find.byKey(const Key('login-submit'));
await tester.ensureVisible(submit);
await tester.pump();
await tester.tap(submit);
await tester.pump();
expect(find.text('Email is required'), findsOneWidget);
});go deeper
Recall the main finders - byKey, byType, text - and that a tap needs a finder that matches exactly one widget.
Explain the three tap failures: nothing found, several found, and a centre that misses because of the 800 x 600 surface or an overlay, plus skipOffstage's default.
Make hit-test warnings fatal in shared config, target controls by key, keep text finders for assertions, and scroll or resize the view instead of silencing misses.
Agree on a keying convention for testable controls so finders survive copy and localisation changes across teams.
## What a finder is A **`Finder`** is a lazy description of which widgets to locate. Nothing is searched until it is evaluated - by `expect`, by `tester.tap`, or by `tester.widget`. It is evaluated against the **current** element tree, so the same finder can match nothing before a pump and one widget after. The common constructors on the global `find` object: | Finder | Matches | Good for | |---|---|---| | `find.byKey(key)` | the widget whose `key` equals `key` | stable targeting of controls you own | | `find.byType(Type)` | widgets whose runtime type is exactly that type | "one dialog is shown", "no spinner" | | `find.text(String)` | `Text`, `Text.rich` by plain text, and `EditableText` by controller value | asserting copy the user reads | | `find.widgetWithText(Type, String)` | a widget of that type with matching text in its subtree | a button identified by type plus label | | `find.byIcon(IconData)` | `Icon` widgets with that icon | icon-only buttons | | `find.descendant` / `find.ancestor` | combinations of the above | narrowing inside a list row | Every one of these takes `skipOffstage`, **true by default**, which skips widgets inside `Offstage` and in **inactive routes** - so a button on the page below a pushed dialog route is not found until the route becomes active again. ## Why a tap can throw A gesture needs a single target. When the finder is evaluated for `tap` (the same code serves `longPress`, `drag`, `fling` and friends): 1. **Zero matches** throws "The finder ... could not find any matching widgets". The widget was never built (a pump is missing), is in an inactive route, or is lazily built further down a `ListView`. 2. **Several matches** throws "... ambiguously found multiple matching widgets. The "tap()" method needs a single target." On a login screen this is classic: the `AppBar` title and the submit button both read **Sign in**. 3. The match must have a `RenderBox`; otherwise the tap has no geometry to aim at. ## Why a tap can "succeed" and do nothing After resolving one widget, `tap` computes its centre and taps there. With `warnIfMissed: true` (the default), it hit-tests that point and checks whether the target is in the result. If not, it prints a warning explaining the offset would not hit the widget - and it throws instead when `WidgetController.hitTestWarningShouldBeFatal` is true (default false, often switched on in a shared `flutter_test_config.dart`). Typical causes: - The button sits **below the 800 x 600 logical-pixel test surface** in a scrollable form; its centre is outside the root view. - A **modal barrier or dialog** covers it. - An `IgnorePointer` or `AbsorbPointer` wraps it on purpose - in which case pass `warnIfMissed: false`, because the test is asserting that the tap has no effect. Treat this warning as a failure in waiting: the tap did not reach the button, so a later assertion fails for a misleading reason. ## Fixes for a login form - **Use keys for controls**: `find.byKey(const Key('login-submit'))` is immune to copy changes and duplicates. - **Or disambiguate by type**: `find.widgetWithText(FilledButton, 'Sign in')`, or `find.descendant(of: find.byType(Form), matching: find.text('Sign in'))`. - **Bring it into view**: `await tester.ensureVisible(finder); await tester.pump();`, or `await tester.scrollUntilVisible(finder, 200)` when a lazy list has not built it yet (it repeatedly drags the `Scrollable` by the given delta, up to 50 times by default). - **Or change the surface**: set `tester.view.physicalSize` (and `devicePixelRatio`), with `addTearDown(tester.view.reset)` so later tests get the default back. - **Keep `find.text` for assertions**: `expect(find.text('Password is required'), findsOneWidget)` tests what the user sees. ## Interactions beyond tap - `tester.enterText(finder, '[email protected]')` requires the finder to be, or contain, an `EditableText` (so `find.byType(TextFormField)` or a key on the field works). - `tester.drag(finder, const Offset(0, -300))` drags from the widget's centre by that offset - the same single-target and hit-test rules apply - and like every action it is followed by a pump.
- Why might a finder that works in one test find nothing after a Navigator.push in another?Finders skip offstage widgets and inactive routes by default (`skipOffstage: true`). While the transition runs, both pages are built and on stage, so a text present on both matches twice. Once it settles (`pumpAndSettle()` or enough pumps), the page beneath an opaque route is offstage and skipped by default, so its widgets are no longer found.
- When is warnIfMissed: false the right choice?When the test deliberately taps something that should not receive the event - a button behind `IgnorePointer`, or a disabled area under a barrier - and then asserts nothing happened. Anywhere else the warning is a real miss to fix.
- How does find.byType differ from find.bySubtype?`find.byType(T)` matches widgets whose runtime type is exactly `T`; `find.bySubtype<T>()` also matches subclasses. A custom `MyButton extends FilledButton` is found by `bySubtype<FilledButton>()` but not by `byType(FilledButton)`.
saying these in an interview costs you the question
- tap simply taps the first widget when a finder matches several
- A hit-test warning is harmless noise once the test passes
- The test surface is as tall as the whole scrollable form
- find.byType also matches subclasses of the given type
- Finders still see the page beneath a settled full-screen route by default