Flutter and Widget Tests

Getting Started

Unit tests are critical if you want to make sure that you don't accidentally break your app with an innocuous code change. In this post, we're going to discuss the basics of widget testing (unit testing widgets) using flutter. We'll be using a basic TodoMVC app we wrote just for this purpose.

Download

Visit the GitHub repository to clone the source. This is highly recommended so you can play with the app.

You will also need to have flutter setup so that you can run the tests. Head over to the official documentation for instructions on how to set up your development environment: https://flutter.dev/docs/get-started/install

What's the point?

The point is to not break things. We've built this app with the intention of migrating it to use firebase for persistence. Right now, it just uses the state and you lose everything if you close the app. It's not very useful.

However, it's useful enough to add tests to make sure it all works. That way, when we add in our firebase functionality, we can verify that everything continues to work as expected.

Our first widget test

We have 3 parts to our app: pages, widgets, models. Pages are where the business logic, heavy lifting, etc. happen. Widgets are mostly dumb components that display data and provide the UX. Models are classes that sit around just to store data.

So, we're going to start off with our most simple component and create a widget test for our header_widget (lib/src/widgets/todo_header.dart).

The header has two states: empty todo list, and at least one todo. It has two functions: create a new todo, and toggle all the existing todos.

Since this is a dumb component, we'll just verify that the callbacks are called correctly.

test/widgets/todo_header_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:todomvc/src/todo_app.dart';

void main() {
  testWidgets('Header adds todo', (WidgetTester tester) async {
    Key inputKey = new UniqueKey();
    String value;

    await tester.pumpWidget(
        new MaterialApp(
          home: new Material(
            child: new TodoHeaderWidget(
              textInputKey: inputKey,
              onAddTodo: (title) {
                value = title;
              },
            ),
          ),
        ));
    expect(value, isNull);

    await tester.enterText(find.byKey(inputKey), "test-todo\n");
    TextField f = tester.widget(find.byKey(inputKey));
    f.onSubmitted(f.controller.value.text);
    expect(value, equals("test-todo"));

    // No toggle all button
    expect(find.byType(IconButton), findsNothing);
  });

  testWidgets('Shows the toggle button', (WidgetTester tester) async {
    bool called = false;

    await tester.pumpWidget(
        new MaterialApp(
          home: new Material(
            child: new TodoHeaderWidget(
              showToggleAll: true,
              onChangeToggleAll: () {
                called = true;
              },
              onAddTodo: (title) {},
            ),
          ),
        ));
    expect(called, isFalse);

    await tester.tap(find.byType(IconButton));

    expect(called, isTrue);
  });
}

The main function is what is executed by the test runner, so we need it for anything to work. The calls to testWidgets denote each test that we want to run. The first argument is the name and the second argument is a callback to execute our test.

Our 'Header adds todo' test is testing that after we enter text into the header box, it properly calls the addTodo callback. Since our TodoHeaderWidget is a StatelessWidget, this is pretty easy to set up. Just have the callback set a value in the parent scope. Done. The only other complicated part is this: f.onSubmitted(f.controller.value.text);. I chose to use the onSubmitted callback for the textbox (this callback is called when focus is lost, or "enter"/"done" is press on the keyboard), and there is - currently - no way I could find to simulate this event with the tester. So, we just called the method manually.

Our 'Shows the toggle button' is actually testing two things, even though it only has expect statements for one. First, it's testing that the toggle button is actually displayed when showToggleAll: true, then it's testing that we can interact with it.

I'm not going to go over test/widgets/todo_widget_test.dart because it's basically the same as the header tests.

Testing with child widgets

Our TodoListPage is a much more complicated component, as it composes our other widgets. We want to be able to test that they all work together properly. First, let's talk about our createSUT (create system under test) method.

test/pages/todo_list_test.dart
  Future<Null> createSUT(WidgetTester tester, {List<TodoItem> todos, TypeFilter filter}) async {
    await tester.pumpWidget(
        new MaterialApp(
          home: new Material(
            child: new TodoList(),
          ),
        ));

    // Flags for our change requirements
    final bool setTodos = todos != null && todos.isNotEmpty;
    final bool setFilter = filter != null;

    if (setTodos || setFilter) {
      final TodoListState listState = tester.state<TodoListState>(find.byType(TodoList));
      listState.setState(() {
        if (setTodos) {
          listState.todos = todos;
        }
        if (setFilter) {
          listState.typeFilter = filter;
        }
      });

      await tester.pump();
    }
  }

I wanted the ability to set initial values for the todos and filter. Since these are managed by the widget's state, I could only do that after the widget was initialized. We abstract all that in this method. We created and initialize the widget with the call to pumpWidget. If we have defaults we want to set, we grab the state with final TodoListState listState = tester.state..., then set the values with our call to listState.setState(...). We then wait for everything with our final tester.pump().

Let's see how this looks when used by a test.

test/pages/todo_list_test.dart
  testWidgets('toggles a widget properly out of the filtered group', (WidgetTester tester) async {
    await createSUT(tester,
      todos: <TodoItem>[
        new TodoItem(id: 'first', title: 'First Todo - Active'),
        new TodoItem(id: 'second', title: 'Second Todo - Completed', completed: true),
        new TodoItem(id: 'third', title: 'Third Todo - Active'),
      ],
      filter: TypeFilter.ACTIVE,
    );

    final Finder todos = find.byType(TodoWidget);
    expect(todos, findsNWidgets(2));

    // Toggle an item to completed
    await tester.tap(find.descendant(
      of: todos.first,
      matching: find.byType(Checkbox),
    ));
    await tester.pump();

    // Verify the widget no longer shows up
    expect(find.byType(TodoWidget), findsOneWidget);
  });

You can see in the usage of createSUT that we pass in our initial todos and the filter. We then verify that only the 2 active widgets are being displayed. Next, we tap the Checkbox on the first todo. Finally, we verify that there is now only one todo being displayed.

Note here that the Checkbox is inside of our child TodoWidget. We are able to intelligently drill down through the entire widget tree to find the components that we need interaction.

We use this same style test in 'toggles all todos back and forth', but we tap on a different descendant.

    final Finder toggleAll = find.descendant(
      of: find.byType(TodoHeaderWidget),
      matching: find.byType(IconButton),
    );

Here, we find the Header, then find the child IconButton and click it.

Continue on until you've tested all the different click actions and their behavior.

The more tests, the merrier!

Brian Armstrong

Brian Armstrong

I'm Brian Armstrong, a SaaS developer with 15+ years programming experience. I am a Flutter evangelist and React.js enthusiast.