An in-depth analysis of testing widgets in Flutter. Part II. Finder and WidgetTester Classes

The translation of the material was prepared as part of the online course Flutter Mobile Developer“.

We also invite everyone to a free two-day intensive “We create a Flutter application for Web, iOS and Android”… On the intensive course, we will learn exactly how Flutter allows you to create applications for the Web platform, and why it is now stable functionality; how the web assembly works. Let’s write an application with networking. Details and registration here


This is a sequel the first part of the article about testing widgets in Flutter

Let’s continue our exploration of the widget testing process.

Last time we focused on the basic structure of the test file and looked in detail at what the function can do. testWidgets() in the test. Although this function is responsible for executing the test, we did not go directly to the test and did not even see how it looks – and this was done on purpose. In my opinion, a good knowledge of the components that make up the test can be of immense benefit at the time of writing.

A small summary of the previous part of the article:

  1. Widget tests are for testing small components of an application.

  2. We save our tests in the test folder.

  3. Inside the function testWidgets() we write tests of widgets, and we examined in detail the composition of this function.

Let’s continue our analysis.

How is the test of the widget written?

A widget test usually provides an opportunity to check:

  1. Whether visuals are displayed.

  2. Whether interacting with visuals is producing the right result.

Let’s start with the second task, and the first will pull up by itself as a derivative. To do this, we usually perform the following steps during testing:

  1. We set the initial conditions and create a widget for testing.

  2. We find visual elements on the screen using some property (for example, a key).

  3. We interact with elements (for example, a button) using the same identifier.

  4. Make sure the results are as expected.

Creating a widget for testing

To test the widget, obviously, we need the widget itself. Let’s take a look at the default test in the test folder:

void main() {

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here
    },
  );

}

Surely you noticed the object WidgetTester in the callback function where we write our test. It’s time to apply it.

To create a new widget for testing, use the method pumpWidget():

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
          ),
        ),
      );
    },
  );

(Do not forget about awaitotherwise the test will throw a bunch of errors.)

This method creates a widget for testing.

Learn more about WidgetTester we’ll talk a little later, first we need to deal with another issue.

Crawlers

I must admit that in the process of writing this article, the concept of “search” has caused me a persistent feeling jamevue, which could not be completely shaken off to this moment.

If in the first step we create an instance of the widget for testing, then the second step is to find the visual element with which we want to interact – it can be a button, text, etc.

So how do you find a widget? For this we use a searcher object, the class Finder… (You can search for items as well, but that’s a different topic.)

Simple in words, but in reality, you need to define something unique to the widget – type, text, descendants or ancestors, etc.

Let’s take a look at some of the more common and some more specific ways to find widgets:

find.byType ()

Let’s take a look at finding a Text widget as an example:

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
            body: Center(
              child: Text('Hi there!'),
            ),
          ),
        ),
      );

      var finder = find.byType(Text);
    },
  );

Here we use a predefined instance of the class to create the searcher object CommonFinders under the name find… Function byType() helps us find ANY widget of a certain type. Thus, if there are two text widgets in the widget tree, BOTH will be identified. Therefore, if you want to find a specific widget Text, consider adding a key to it or using the following type:

find.text ()

To find a specific Text widget, use the function find.text():

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
            body: Center(
              child: Text('Hi there!'),
            ),
          ),
        ),
      );

      var finder = find.text('Hi there!');
    },
  );

This also applies to any widget like EditableText, for example a widget TextField

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here

      var controller = TextEditingController.fromValue(TextEditingValue(text: 'Hi there!'));

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
            body: Center(
              child: TextField(controller: controller,),
            ),
          ),
        ),
      );

      var finder = find.text('Hi there!');
    },
  );

find.byKey ()

One of the most common and easiest ways to find a widget is to simply add a key to it:

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
            body: Center(
              child: Icon(
                Icons.add,
                key: Key('demoKey'),
              ),
            ),
          ),
        ),
      );

      var finder = find.byKey(Key('demoKey'));
    },
  );

find.descendant () and find.ancestor ()

This is a more specific type that can be used to find a descendant or ancestor of a widget that has certain properties (for which we again use a searcher object).

Let’s say we want to find an icon that is a descendant of a Center widget that has a key. We can do it like this:

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
            body: Center(
              key: Key('demoKey'),
              child: Icon(Icons.add),
            ),
          ),
        ),
      );
      
      var finder = find.descendant(
        of: find.byKey(Key('demoKey')),
        matching: find.byType(Icon),
      );
    },
  );

Here we indicate that the desired widget is a descendant of the Center widget (for this, the parameter of) and corresponds to the properties we set again with the searcher object.

Call find.ancestor() is similar in many ways, but the roles are reversed as we are trying to find the widget located above the widget defined using the parameter of

If we were trying to find the Center widget here, we would do the following:

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
            body: Center(
              key: Key('demoKey'),
              child: Icon(Icons.add),
            ),
          ),
        ),
      );

      var finder = find.ancestor(
        of: find.byType(Icon),
        matching: find.byKey(Key('demoKey')),
      );
    },
  );

Creating a custom crawler object

When using functions like find.xxxx() we are using a predefined class Finder… What if we want to use our own way to find a widget?

Continuing a series of bad examples, suppose we want a crawler object that finds all the badges that have no keys. Let’s call this object BadlyWrittenWidgetFinder

  1. First, let’s add the class MatchFinder

class BadlyWrittenWidgetFinder extends MatchFinder {
  
  @override
  // TODO: implement description
  String get description => throw UnimplementedError();

  @override
  bool matches(Element candidate) {
    // TODO: implement matches
    throw UnimplementedError();
  }
  
}

2.Using the function matches() we check if the widget meets our conditions. In our case, we have to check whether the widget is an icon and whether its key is equal to the value null:

class BadlyWrittenWidgetFinder extends MatchFinder {

  BadlyWrittenWidgetFinder({bool skipOffstage = true})
      : super(skipOffstage: skipOffstage);

  @override
  String get description => 'Finds icons with no key';

  @override
  bool matches(Element candidate) {
    final Widget widget = candidate.widget;
    return widget is Icon && widget.key == null;
  }

}

3. Taking advantage of extensions, we can add this searcher object directly to the class CommonFinders (an object find is an instance of this class):

extension BadlyWrittenWidget on CommonFinders {
  Finder byBadlyWrittenWidget({bool skipOffstage = true }) => BadlyWrittenWidgetFinder(skipOffstage: skipOffstage);
}

4. Thanks to extensions, we can refer to the searcher object just like any other object:

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            appBar: AppBar(),
            body: Center(
              key: Key('demoKey'),
              child: Icon(Icons.add),
            ),
          ),
        ),
      );

      var finder = find.byBadlyWrittenWidget();
    },
  );

Now that we are familiar with searcher objects, let’s move on to examining the class WidgetTester

Everything you need to know about WidgetTester

This is a fairly large topic that deserves a separate article, but we will try to consider the main points here.

Class WidgetTester allows us to interact with the test environment. Widget tests do not run exactly as they would on a real device, because the asynchronous behavior is simulated in the test. Another difference should be noted:

In the widget test, the function setState() does not work the way it normally does.

Although the function setState() marks the widget to be rebuilt, in reality it does not rebuild the widget tree in the test. So how do we do this? Let’s take a look at the methods pump

What are pump methods for?

In short: pump() initiates a new frame (rebuilds the widget), pumpWidget() sets the root widget and then initiates a new frame, while pumpAndSettle() calls the function pump() until the widget stops asking for new frames (usually when the animation is running).

A little about the pumpWidget () function

As we saw earlier, the function pumpWidget() used to set the root widget for testing. It calls the function runApp()using the specified widget, and calls the function internally pump()… When called again, the function rebuilds the entire tree.

More about the pump () function

We have to call the function pump()to actually rebuild the widgets we want. Let’s say we have a standard counter widget like this:

class CounterWidget extends StatefulWidget {
  @override
  _CounterWidgetState createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  var count = 0;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Text('$count'),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add),
          onPressed: () {
            setState(() {
              count++;
            });
          },
        ),
      ),
    );
  }
}

The widget just stores the counter value and updates it when the button is clicked FloatingActionButtonlike in a standard counter app.

Let’s try to test the widget: find the add icon and click it to check if the counter becomes 1:

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here
      await tester.pumpWidget(CounterWidget());

      var finder = find.byIcon(Icons.add);
      await tester.tap(finder);
      
      // Ignore this line for now
      // It just verifies that the value is what we expect it to be
      expect(find.text('1'), findsOneWidget);
    },
  );

But no:

The reason is that we are rebuilding the widget Textdisplaying the counter using the function setState() in the widget, but in this case the widget is not rebuilt. We also need to call the method pump():

  testWidgets(
    'Test description',
    (WidgetTester tester) async {
      // Write your test here
      await tester.pumpWidget(CounterWidget());

      var finder = find.byIcon(Icons.add);
      await tester.tap(finder);
      await tester.pump();

      // Ignore this line for now
      // It just verifies that the value is what we expect it to be
      expect(find.text('1'), findsOneWidget);
    },
  );

And we get a nicer result:

If you need to schedule the display of a frame after a certain time, the method pump() you can also pass the time – then the rebuilding of the widget will be scheduled AFTER expiration of the specified time interval:

await tester.pump(Duration(seconds: 1));

Note that the test will not actually wait for the specified time, instead the time counter will be shifted forward by that time.

The method pump there is a useful feature: you can stop it at the desired stage of rebuilding and rendering the widget. To do this, you need to set the parameter EnginePhase of this method:

enum EnginePhase {
  /// The build phase in the widgets library. See [BuildOwner.buildScope].
  build,

  /// The layout phase in the rendering library. See [PipelineOwner.flushLayout].
  layout,

  /// The compositing bits update phase in the rendering library. See
  /// [PipelineOwner.flushCompositingBits].
  compositingBits,

  /// The paint phase in the rendering library. See [PipelineOwner.flushPaint].
  paint,

  /// The compositing phase in the rendering library. See
  /// [RenderView.compositeFrame]. This is the phase in which data is sent to
  /// the GPU. If semantics are not enabled, then this is the last phase.
  composite,

  /// The semantics building phase in the rendering library. See
  /// [PipelineOwner.flushSemantics].
  flushSemantics,

  /// The final phase in the rendering library, wherein semantics information is
  /// sent to the embedder. See [SemanticsOwner.sendSemanticsUpdate].
  sendSemanticsUpdate,
}

await tester.pump(Duration.zero, EnginePhase.paint);

Note. I used the enumeration in the source code just to illustrate the steps more clearly. Don’t add it to your code.

Go to pumpAndSettle ()

Method pumpAndSettle() Is, in fact, the same pump method, but called until the moment when no new frames are scheduled. It helps complete all animations.

It has similar parameters (time and stage), as well as an additional parameter – a timeout that limits the time to call this method.

await tester.pumpAndSettle(
        Duration(milliseconds: 10),
        EnginePhase.paint,
        Duration(minutes: 1),
      );

Interaction with the environment

Class WidgetTester allows us to use complex interactions in addition to the usual search + touch interactions. Here’s what you can do with it:

Method tester.drag() allows you to initiate dragging from the middle of the widget, which we find with the finder object at a specific offset. We can set the direction of the drag by specifying the appropriate X and Y offsets:

      var finder = find.byIcon(Icons.add);
      var moveBy = Offset(100, 100);
      var slopeX = 1.0;
      var slopeY = 1.0;

      await tester.drag(finder, moveBy, touchSlopX: slopeX, touchSlopY: slopeY);

We can also initiate a timed drag using the method tester.timedDrag():

      var finder = find.byIcon(Icons.add);
      var moveBy = Offset(100, 100);
      var dragDuration = Duration(seconds: 1);

      await tester.timedDrag(finder, moveBy, dragDuration);

To simply drag an object from one position on the screen to another without resorting to the searchers, use the method tester.dragFrom(), which allows you to initiate dragging from the desired position on the screen.

      var dragFrom = Offset(250, 300);
      var moveBy = Offset(100, 100);
      var slopeX = 1.0;
      var slopeY = 1.0;

      await tester.dragFrom(dragFrom, moveBy, touchSlopX: slopeX, touchSlopY: slopeY);

There is also a time-controlled variant of this method – tester.timedDragFrom()

      var dragFrom = Offset(250, 300);
      var moveBy = Offset(100, 100);
      var duration = Duration(seconds: 1);

      await tester.timedDragFrom(dragFrom, moveBy, duration);

Note. If you want to simulate swiping, use the method tester.fling() instead tester.drag()

Creating custom gestures

Let’s try to create our own gesture: touching a specific position and “drawing” a rectangle on the screen, returning to its original position.

First, we need to initialize the gesture:

      var dragFrom = Offset(250, 300);

      var gesture = await tester.startGesture(dragFrom);

The first parameter determines where the initial touch of the screen occurs.

We can then use the following code to create our own gesture:

      var dragFrom = Offset(250, 300);

      var gesture = await tester.startGesture(dragFrom);
      
      await gesture.moveBy(Offset(50.0, 0));
      await gesture.moveBy(Offset(0.0, -50.0));
      await gesture.moveBy(Offset(-50.0, 0));
      await gesture.moveBy(Offset(0.0, 50.0));
      
      await gesture.up();

During testing, other possibilities are also available, such as getting the positions of the used widgets, interacting with the keyboard, and so on. These are usually trivial things, and I, perhaps, will talk about them in one of the following articles (when I write this, it is already five in the morning – maybe my reluctance to go into details has something to do with this, who knows) …

In this part of the article, we learned about the peculiarities of working with classes. Finder and WidgetTester… Next, we will complete our acquaintance with the process of testing widgets and explore additional testing options – this will be in the third part of the article.


More about the course Flutter Mobile Developer“.

Participate in an intensive “We create a Flutter application for Web, iOS and Android”

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *