Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Improve Finder APIs #115874

Closed
Closed
@bartekpacia

Description

@bartekpacia

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:

    1. Making the Finder class represent only a finder (i.e what to search for in the widget tree)
    2. Creating a new EvaluatedFinder class containing what the finder found
    3. Making Finder.evaluate return EvaluatedFinder

    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:

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projecta: tests"flutter test", flutter_test, or one of our testsc: API breakBackwards-incompatible API changesc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Flutterframeworkflutter/packages/flutter repository. See also f: labels.r: fixedIssue is closed as already fixed in a newer versionteam-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions