Description
I don't know why the original creators of Flutter's test framework designed it the way it is designed, and I'm not blaming them in any way – they did a really great job at making testing feel like a first-class citizen in Flutter. I do like writing tests in Flutter :)
That said, I think there's some room for improvement.
The problem
I think that Finder
coalescing the "representation of what to search for" and the "result of performing that search" into a single concept has a few nasty consequences. I'll do my best to explain them below.
I was wondering if I should've split this issue into a few separate ones, but I decided not to because I think that they share a common cause.
Finder.toString()
First example (1 widget found)
When I called Finder.toString()
for the first time in my short life, I expected it to print what the finder searches for, not what it found. Here's an example:
testWidgets("Prints finder's description", (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: Center(child: Text('Hello World'))),
);
final finder = find.text('Hello World');
print(finder.toString());
});
Actual output
exactly one widget with text "Hello World" (ignoring offstage widgets): Text("Hello World", dependencies: [DefaultSelectionStyle, DefaultTextStyle, MediaQuery])
I created a Finder and want to print it, but it's already searched the widget tree and now is printing what it found?
BTW, why so fast? I didn't even ask it to find. Take your time, you finder guy.
Better output
widgets with text "Hello World" (ignoring offstage widgets)
I want to print what the finder intends to find, not what it's already found.
Even better output
Since offstage widgets are ignored by default, I don't think it makes sense to include this info in the toString()
's output. It should only be present if skipOffstage
was set to false
. So the perfect output of toString()
would be:
widgets with text "Hello World"
PS It'd be nice to rename skipOffstage
to includeOffstage
and change the default value to false
, to be consistent with Flutter's Dart code style. See also this comment.
Why does it matter
An example use case is described in this GitHub Discussion.
Also, there's no way to currently print only what the finder searches for, without all the noise.
Second example (>1 widgets found)
The more widgets the finder finds, the more cluttered the output becomes.
testWidgets("Prints finder's description 2", (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Center(
child: Column(
children: const [
Text('Hello World'),
Text('Hello World'),
],
),
),
),
);
final finder = find.text('Hello World');
print(finder.toString());
});
Actual output
2 widgets with text "Hello World" (ignoring offstage widgets): [Text("Hello World", dependencies: [DefaultSelectionStyle, DefaultTextStyle, MediaQuery]), Text("Hello World", dependencies: [DefaultSelectionStyle, DefaultTextStyle, MediaQuery])]
Better output
widgets with text "Hello World"
Third example (no widgets found)
And when the finder doesn't find any widgets, the output reads awkwardly.
testWidgets('Finder.toString() 3', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp());
final finder = find.text('Hello World');
print(finder.toString());
});
Actual output
zero widgets with text "Hello World" (ignoring offstage widgets)
Better output
widgets with text "Hello World"
Instead of changing toString()
behavior, we could create a new abstract method Finder.toRepresentation()
(inspired by Python's repr()), and make all the other finders override it.
Finder.at()
Let's consider the below code:
testWidgets('Finder.at() + Finder.toString() 3', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp());
final finder = find.text('Hello World').at(0);
print(finder.toString());
});
Actual result
A nasty exception is thrown:
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following IndexError was thrown running a test:
RangeError (index): Index out of range: no indices are valid: 0
When the exception was thrown, this was the stack:
#0 Iterable.elementAt (dart:core/iterable.dart:781:5)
#1 _IndexFinder.filter (package:flutter_test/src/finders.dart:624:28)
#3 new _GrowableList._ofOther (dart:core-patch/growable_array.dart:202:26)
#4 new _GrowableList.of (dart:core-patch/growable_array.dart:152:26)
#5 new List.of (dart:core-patch/array_patch.dart:51:28)
#6 Iterable.toList (dart:core/iterable.dart:470:12)
#7 Finder.toString (package:flutter_test/src/finders.dart:552:46)
#8 main.<anonymous closure> (file:///Users/bartek/dev/random/flutter_what_are_finders/test/widget_test.dart:57:18)
<asynchronous suspension>
<asynchronous suspension>
(elided 2 frames from dart:async-patch and package:stack_trace)
The test description was:
Finder.at() + Finder.toString() 3
I've seen testers and developers encounter this problem and become confused.
Result that would be better
Let's see the docs for the Finder.at() method:
(int index) → Finder
Returns a variant of this finder that only matches the element at the given index matched by this finder.
After reading the above, I'd expect the above test case to throw a:
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: exactly one matching node in the widget tree
Actual: _TextFinder:<zero widgets with text "1" (ignoring offstage widgets)>
Which: means none were found but one was expected
but instead, it throws a StateError
, as shown above.
The best result
widget with text "Hello World" at index 0, or even better: first widget with text "Hello World"
Conclusion
-
At the very last, docs of the Finder class should explain what is a finder, not only that "it finds".
-
Finder.toString() calling Finder.evaluate() is a bad idea.
-
Flutter's test framework should differentiate between 2 (related but distinct) concepts: the finder itself and the finder's result.
Speaking more technically, it means:- Making the Finder class represent only a finder (i.e what to search for in the widget tree)
- Creating a new
EvaluatedFinder
class containing what the finder found - Making
Finder.evaluate
returnEvaluatedFinder
I realize that the last bullet point is a huge suggestion - it would most certainly be a breaking change, and I'm not sure if the benefits I listed above outweigh the migration pain. I'll try to explore this in a paragraph below.
Exploring Finder
and EvaluatedFinder
WidgetTester.tap(), WidgetTester.enterText() (and some friends) would require EvaluatedFinder
as the first argument (instead of Finder
).
Widget matchers (see: matchers.dart), such as _FindsWidgetMatcher.matches()
would require EvaluatedFinder
as the second argument (instead of Finder
).
Here's an example test written using that imagined API:
testWidgets('increments count by 1', (WidgetTester tester) async {
var count = 0;
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (state, setState) => Column(
children: [
Text('count: $count'),
GestureDetector(
onTap: () => setState(() => count+=5),
child: const Text('Tap'),
),
GestureDetector(
onTap: () => setState(() => count++),
child: const Text('Tap'),
),
],
),
),
),
);
final tapFinder = find.text("Tap");
final tapFinderResult = tapFinder.evaluate().at(1);
await tester.tap(tapFinderResult).tap();
await tester.pumpAndSettle();
final countFinder = find.text("count: 1");
expect(countFinder.evaluate()), findsOneWidget);
});
It's a bit more clunky, but that's the price to be paid for separating a finder from its result.
I low-key think that this slightly more clunky (but mapping the problem space better) finder system should've been provided by Flutter by default, and then the current "fluent" finder system could be provided as a separate package.
Alternative
If we don't want to break thousands of projects with millions of tests, we could take an alternative approach. The new finder system would be provided as a separate package, say, flutter_test_plus
. Since the core flutter
package is independent of flutter_test
, I don't see why a third party wouldn't be able to, purely technically speaking, provide such a finder system.
See also: