Testing In Flutter: Widget Test

Testing In Flutter: Widget Test

What is a Widget test? What are pumpWidget(), pump() & pumpAndSettle()? How to test smaller widgets in Isolation?

Dhruv Nakum
·Mar 2, 2022·

10 min read

Subscribe to my newsletter and never miss my upcoming articles

Play this article

Table of contents

  • Introduction
  • Widget Test
  • Difference Between pumpWidget(), pump() & pumpAndSettle()
  • Finding a Widget in Widget Tree Using CommonFinders
  • Interacting with Your Widgets Using WidgetTester
  • Testing Smaller Widgets in Isolation
  • Wrapping Up

Introduction

  • In the previous blog, we saw the Unit Test in Flutter.
  • Unit Testing is a great testing technique. It allows us to test a single function, method, or class individually. This is a fantastic way to make sure that a unit of logic produces the expected value.
  • However, they don't provide much confidence since they focus on small pieces of code and not on how the pieces integrate with each other.
  • In this blog, we are going to see another testing technique in Flutter which is Widget Test.

Widget Test

  • Widget testing is a technique that allows us to ensure that various portions of the user interface work as intended without the need for a physical device or simulator.
  • They can be a great way to isolate small parts of your app and find out whether your code is behaving as expected.
  • They usually increase the level of confidence compared to unit tests. And the execution time allows us to run hundreds of tests in less than a minute.
  • They will seem familiar to you since they are very similar to the unit test that we have previously written.
  • However, due to the flutter_test package, we can utilize several handy tools and utilities to interact with our application.
  • To continue with our calculator project, we will now add widget testing to our flutter application.
  • The first step is to make a test folder if you don't already have one.
  • Next, we'll create our first test file, calculator_app_test.dart, in which we'll write our test.
  • Now we are going to create our first tests. This time however we need to make sure that we import the flutter_test package so we can use the testWidgets() method instead of the previous test() method that we use for unit test.
import 'package:flutter_test/flutter_test.dart';

void main() {
  group(' ', () {
    testWidgets(' ',
        (WidgetTester tester) async {
       }
    );
  });
}
  • To begin testing, we must first develop a page that has some widgets. Let's make a CalculatorApp page that displays four ListTile widgets. Which displays several icons for operations and related titles.
import 'package:flutter/material.dart';

void main() {
  runApp(const CalculatorPage());
}

class CalculatorPage extends StatelessWidget {
  const CalculatorPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text("Calculator"),
        ),
        body: Column(
          children: const [
            ListTile(
              title: Text("Addition"),
              leading: Icon(Icons.add),
            ),
            ListTile(
              title: Text("Subtraction"),
              leading: Icon(Icons.minimize),
            ),
            ListTile(
              title: Text("Multiplication"),
              leading: Icon(Icons.cancel_outlined),
            ),
            ListTile(
              title: Text("Division"),
              leading: Icon(Icons.architecture),
            ),
          ],
        ),
      ),
    );
  }
}
calculatorapp.png
  • To test these widgets, navigate to the calculator app test.dart file.
  • Now create a group called 'CalculatorApp.'
    void main() {
       group('CalculatorApp', () {
       });
    }
    
  • Then insert the test description within the testWidgets method. testWidgets is like test in unit testing, but for widget testing.
    void main() {
       group('CalculatorApp', () {
         testWidgets('Render 4 widgets of Type ListTile',
             (WidgetTester tester) async {
         });
       });
    }
    
  • The function takes one argument called WidgetTester, which is used to interact with the application.
  • When we launch the application, we can see four list tile widgets on the screen. As a result, the most basic test we can write here is: When we launch the application, the four widgets are rendered.
  • In a flutter, we use the function runApp to inflate our application and attach it to the screen of the device.
  • Similarly the pumpWidget method from the widget tester class achieves the same. On a test environment it renders the initial UI of the given widget.
    void main() {
       group('CalculatorApp', () {
         testWidgets('Render 4 widgets of Type ListTile',
             (WidgetTester tester) async {
           await tester.pumpWidget(const CalculatorPage());
           expect(find.byType(ListTile), findsNWidgets(4)); // Expecting 4 ListTile widget on    Screen. 
         });
    });
    }
    

calculatorapp_test passes.png


Difference Between pumpWidget(), pump() & pumpAndSettle()

  • Interacting with our application is crucial during the testing phase. After all, being able to tap a button, fill a form, or scroll through views is what adds value to our project.
  • Consider the following example, where I’ve created two Textfield for inputting two numbers for calculation. I also gave each of the Textfield a unique Key So that we can find that particular widget inside our tests.

pump.gif

  • As you can see as soon as I fill up the Textfields the result gets appeared with a little background color animation.
  • Let’s see how to test this behavior in our Calculator Test file.
  • First of all, let’s create a group named add. And then using pumpWidget let’s render the CalculatorPage.
group('add',(){
    testWidget('Show result when two inputs are given',(WidgetTester tester) async{
        await tester.pumpWidget(const CalculatorPage())
    })
})
  • Now that we’ve rendered the UI. It’s time to input the numbers in the TextField. In order to find the particular TextField, I’ve assigned a unique Key to each of them.
  • In order to enter the value from the test, the WidgetTester provides us a method called enterText().
    group('add',(){
      testWidget('Show result when two inputs are given',(WidgetTester tester) async{
          await tester.pumpWidget(const CalculatorPage())
          await tester.enterText(find.byKey(const Key('textfield_top_plus')), '3');
          await tester.enterText(find.byKey(const Key('textfield_bottom_plus')), '6');
      })
    })
    
  • It takes two parameters:
    • Finder: This must be an EditableText or have an EditableText descendant. For example TextField or TextFormField , or EditableText.
    • String: It is basically the value which we want to be entered in the Field.
  • Once the text is typed into the textfield, the result is shown, as seen in the output above.
  • So now we can check if there is a text widget on the screen that has the string Result: 9 or not.
  • To do this, we can use the find.text() function to obtain the Text, and then use findsOneWidget to determine whether or not any widget has the expected string.
group('add', () {
    testWidgets('Show result when two inputs are given',
        (WidgetTester tester) async {
      await tester.pumpWidget(const CalculatorPage());
      await tester.enterText(find.byKey(const Key('textfield_top_plus')), '3');
      await tester.enterText(find.byKey(const Key('textfield_bottom_plus')), '6');
      expect(find.text('Result: 9.0'), findsOneWidget);
    });
  });
  • If you run the test, you will get an error that says zero widgets with the text "Result: 9.0". This means none were found but one was expected. Why did this happen?
  • This is due to the fact that when we input a number, the UI changes and shows the result. This change in the UI, or the new frame that was rendered, is exactly what we are lacking in our test.
  • We need to figure out how to inform our widget test to render a new frame after we enter both numbers.
  • The answer is to use the tester instance to invoke the pump() method.
  • pump() instructs the system to paint a new frame so that we can meet our expectations with a newly updated user interface.
group('add', () {
    testWidgets('Show result when two inputs are given',
        (WidgetTester tester) async {
      await tester.pumpWidget(const CalculatorPage());
      await tester.enterText(find.byKey(const Key('textfield_top_plus')), '3');
      await tester.enterText(
          find.byKey(const Key('textfield_bottom_plus')), '6');
      await tester.pump();
      expect(find.text('Result: 9.0'), findsOneWidget);
    });
  });

  • However, the method pump() is rather restricted. Because it just refreshes a single frame, which is useless when working with animations.
  • For instance, in our Calculator Page, let's show the result only when the background animation has finished.
  • To do this, I've created a new variable called resultAfterAnimation, and I'm setting its value when the animation has finished.
AnimatedContainer(
            padding: const EdgeInsets.all(8),
            duration: const Duration(milliseconds: 1000),
            onEnd: () {
              setState(() {
                resultAfterAnimation = result.toString();
              });
            },
            color: result == null ? Colors.transparent : Colors.green,
            curve: Curves.easeInOut,
            child: Text(
              resultAfterAnimation != null
                  ? 'Result: $resultAfterAnimation'
                  : 'Result: ',
              style: Theme.of(context).textTheme.bodyText1,
              textAlign: TextAlign.end,
  ),
),
  • If you try to run the test now, you will receive an error. It's because there is no longer a single frame. The animation is updating many frames at the same time.
  • To wait for the animation to finish, we must use the pumpAndSettle() function provided by the WidgetTest.
  • Simply replace the pump() method with pumpAndSettle() and you're done.
group('add', () {
    testWidgets('Show result when two inputs are given',
        (WidgetTester tester) async {
      await tester.pumpWidget(const CalculatorPage());
      await tester.enterText(find.byKey(const Key('textfield_top_plus')), '3');
      await tester.enterText(
          find.byKey(const Key('textfield_bottom_plus')), '6');
      await tester.pumpAndSettle();
      expect(find.text('Result: 9.0'), findsOneWidget);
    });
  });

Finding a Widget in Widget Tree Using CommonFinders

  • Finding and locating the widget is very important in the Widget Test.
  • The flutter test Package provides top-level find methods that allow us to locate the widgets.
  • While there are plenty of ways to find the widgets, the most common method is finding widgets using byKey().
  • byType(): If we know the class name of the widget that we want to locate or
  • byText(): If what we want to locate is a certain string on the screen
  • You can find more common finders by visiting this link

Interacting with Your Widgets Using WidgetTester

  • Interacting with the Widget is very important in Widget Test. After all what we want to validate in most cases is that after the user interacted with our app like a button tap, our application transitions to a known state that we can assert in our tests.
  • The testWidget() method provides us a callback that gives us tester instance of a WidgetTester.
  • With this object, we can interact with the widget that we have previously inflated with the pumpWidget(). (In our case that is CalculatorPage()) .
  • In our test, we can use tester that ensureVisible() to make sure that a given widget is visible within a scrollable view.
  • Then we can use tester.tap() on TextFormField so they can gain focus.
  • And tester.enterText() to type the given text in the text field .

    group('add', () {
      testWidgets('Show result when two inputs are given',
          (WidgetTester tester) async {
    
        await tester.pumpWidget(const CalculatorPage());
    
        final topTextFieldFinder = find.byKey(const Key('textfield_top_plus'));
        final bottomTextFieldFinder = find.byKey(const Key('textfield_bottom_plus'));
    
        await tester.ensureVisible(topTextFieldFinder);
        await tester.tap(topTextFieldFinder);
        await tester.enterText(topTextFieldFinder, '3');
    
        await tester.ensureVisible(bottomTextFieldFinder);
        await tester.tap(bottomTextFieldFinder);
        await tester.enterText(bottomTextFieldFinder, '6');
    
        await tester.pumpAndSettle();
        expect(find.text('Result: 9.0'), findsOneWidget);
      });
    });
    
  • You can study plenty of other tester methods here

Testing Smaller Widgets in Isolation

  • One of the greatest benefits of widget testing is that we can test different widgets and components in isolation without needing to launch the application.
  • By doing this, our tests can be much more focused, faster and our code base will be much more modular, developer-friendly, and easy to maintain over time.
  • Continuing with our calculator project we are going to apply this principle to the OperationWidget.

operationwidget.png

  • To test this widget in isolation the approach did not differ from what we've already learned.
  • The first step will be to pump our widget under test using pumpWidget().
  • Our OperationWidget has five dependencies in its constructor, the icon, title, two keys, and the operation enum.
  • Now we will pump our OperationWidget using pumpWidget and all the other things remain the same.
main() {
  group('TwoDigit Addition Operation', () {
    testWidgets('render 10 when 5 and 5 added', (tester) async {
      final topTextFieldFinder = find.byKey(const Key('textfield_top_plus'));
      final bottomTextFieldFinder =
          find.byKey(const Key('textfield_bottom_plus'));

      await tester.pumpWidget(
         OperationWidget(
              operationIcon: Icons.add,
              operationTitle: "Addition",
              operationType: OperationType.add,
              textFieldTopKey: 'textfield_top_plus',
              textFieldBottomKey: 'textfield_bottom_plus',
            ),
      );

      await tester.enterText(topTextFieldFinder, '5');
      await tester.enterText(bottomTextFieldFinder, '5');

      await tester.pumpAndSettle();

      expect(find.text('Result: 10.0'), findsOneWidget);
    });
  });
}
  • But now if you run this test, you will get an error saying: Saying The specific widget that could not find a Material ancestor was: TextField.
  • In order to solve this issue, we need to wrap the OperationWidget around MaterialApp and Scaffold widget.
main() {
  group('TwoDigit Addition Operation', () {
    //...
      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: OperationWidget(
              operationIcon: Icons.add,
              operationTitle: "Addition",
              operationType: OperationType.add,
              textFieldTopKey: 'textfield_top_plus',
              textFieldBottomKey: 'textfield_bottom_plus',
            ),
          ),
        ),
      );

     //...
    });
  });
}
  • Now if you will run the test, it should pass.

Wrapping Up

  • I hope you enjoyed and learned something from this article. If you have any feedback/queries, leave them in the comments.
  • In the next blog we are going to see the final testing method: Integration Test
  • I've learned to test and wrote this article using the example provided by VGV. So big thanks to VGV.
  • Thank you for spending time reading this article. See you in the next article. Until then...

PeaceOutImOutGIF.gif

Follow me on : Twitter, LinkedIn, Github

Did you find this article valuable?

Support Dhruv Nakum by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors
 
Share this