When it comes to mobile apps, automated tests play a very important role in app maintenance. So, how do you write tests in Flutter?
As you would expect from a modern framework, Flutter offers both unit and integration tests. But it also offers something else: widget tests.
Widget tests are in between unit and integration. Like a unit test, they run on a simpler test environment (whereas integration tests run on an emulator or device). But they can actually test the UI. You can write a widget test that taps on a button.
With the Android SDK, I write UI tests with Espresso. It works well but the tests take a long time to run. It’s not a problem with CI, as we can use some good sharding libraries. But while I develop, I often run a subset on my local machine, to verify my work, and I get frustrated.
Therefore, I find the concept of widget tests very welcome. It will reduce the number of integration tests on a production app, moving some of them to widget tests.
In this code tutorial, we will create an app with a list page and a details page. On the details page, we can select/unselect an item. Its selected status is shown on the list page via a different background colour.
Then, we will write unit and widget tests to verify the behaviour of the app.

The full source code is available on Github.
General approach
We will use a stream to hold the list of items.
We will load the data from a json file, and not from a server. It keeps this code tutorial free-standing (ie not relying on a server). It doesn’t change anything about the testing, it only changes the implementation of the method that gets the data.
We will add a mechanism to inject test data in the widget test. So we can write a widget test simulating there was an error loading the data for example. To do this, we will introduce the concept of “DataProvider”, which the bloc will use. The bloc will provide a method to pass in a different one.
Setting up the app
To follow the code tutorial, create a new app as follows.
1 |
flutter create widgettests |
If you’re unsure how to set up a Flutter app, check out Getting started with Flutter official tutorial.
There are 2 main widgets, ListPage, and DetailsPage. ListPage is the entry point of the app.
First, we amend main.dart.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import 'package:flutter/material.dart'; import 'package:widgettests/list/list_page.dart'; void main() => runApp(MyApp()); class MyApp extends StatefulWidget { MyApp({Key key}) : super(key: key); @override _MyAppState createState() => _MyAppState(); } class _MyAppState extends State<MyApp> { @override Widget build(BuildContext context) { return MaterialApp( title: 'Widget Tests', theme: ThemeData( primarySwatch: Colors.blue, ), home: ListPage(), ); } } |
Secondly, we create list_page.dart in a new list directory.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import 'package:flutter/material.dart'; import 'package:widgettests/strings.dart'; class ListPage extends StatefulWidget { ListPage({Key key}) : super(key: key); @override _ListPageState createState() => _ListPageState(); } class _ListPageState extends State<ListPage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(LIST_PAGE_TITLE), ), body: Text("Not implemented yet") ); } } |
Thirdly, we create details_page.dart in a new details directory. We will open that screen using the id of the item shown on ListPage, so we pass that in when building that widget.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import 'package:flutter/material.dart'; import 'package:widgettests/strings.dart'; class DetailsPage extends StatefulWidget { DetailsPage({Key key, this.id}) : super(key: key); final int id; @override _DetailsPageState createState() => _DetailsPageState(); } class _DetailsPageState extends State<DetailsPage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Not implemented yet"), ), body: Text("Not implemented yet") ); } } |
Fourthly, we create strings.dart and add all the strings to it. As we fill in the UI, how those strings are used will become clear 😉
1 2 3 4 5 6 7 8 |
const String SELECT_BUTTON = "SELECT"; const String REMOVE_BUTTON = "REMOVE"; const String SELECTED_TITLE = " - Selected"; const String LIST_PAGE_TITLE = "Items"; String getSelectedTitle(String title) { return title + SELECTED_TITLE; } |
Lastly, we add rxdart library to pubspec.yaml and run flutter get packages .
1 2 3 4 5 6 7 |
dependencies: flutter: sdk: flutter cupertino_icons: ^0.1.2 rxdart: ^0.21.0 |
Loading and displaying the list data
Data source
For simplicity, the data is loaded from a json file. Let’s create a top level assets folder, and a data.json file in it. Copy the contents from here.
We also need to include the new asset file in pubspec.yaml.
1 2 3 4 5 6 |
flutter: uses-material-design: true assets: - assets/data.json |
Business Logic Component (BLoC)
For the UI/model architecture, we will use a bloc implemented as a singleton. For more complex apps, I recommend using a BlocProvider as described in Reactive Programming – Streams – BLoC. Mostly, I recommend it, because it handles closing the stream as part of the widget lifecycle. But in our case, we will use a singleton for simplicity, and call its method to call the stream on the dispose method of MyApp.
Let’s create list_bloc.dart, in list directory.
1 2 3 4 5 6 7 8 9 10 11 |
class ListBloc { static final ListBloc _singleton = new ListBloc._internal(); factory ListBloc() { return _singleton; } ListBloc._internal(); } |
We need to set up the model now. We will use Item for an item, and ListOfItem, for the list, which can include either an error message or an actual list of Items. We create a file model.dart for them, in list directory. Item will be loaded from a json file, so let’s write that in.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class Item { final int id; final String title; final String description; final bool selected; Item(this.id, this.title, this.description, this.selected); factory Item.fromJson(Map<String, dynamic> json) { return Item(json['id'] as int,json['title'] as String, json['description'] as String, false); } } class ListOfItems { final List<Item> items; final String errorMessage; ListOfItems(this.items, this.errorMessage); } |
The bloc is a stream of ListofItems. So let’s set that up, not forgetting a method to dispose of the stream, which we call from MyApp, in main.dart.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import 'package:rxdart/rxdart.dart'; import 'package:widgettests/list/model.dart'; class ListBloc { [...] BehaviorSubject<ListOfItems> _itemsController = BehaviorSubject<ListOfItems>(); Stream<ListOfItems> get outItems => _itemsController.stream; void dispose() { _itemsController.close(); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import 'package:widgettests/list/list_bloc.dart'; [...] class _MyAppState extends State<MyApp> { [...] @override void dispose() { ListBloc().dispose(); super.dispose(); } } |
Now, we need to load the data from the json file. To make it testable, we want to abstract the idea of “data provider”. So we create an abstract class, and set up our bloc to use it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import 'package:widgettests/backend/backend_data.dart'; [...] class ListBloc { [...] ItemsDataProvider provider = BackendData(); void injectDataProviderForTest(ItemsDataProvider provider) { this.provider = provider; } } abstract class ItemsDataProvider { Future<ListOfItems> loadItems(); } |
You’ve noticed BackendData above? That’s our implementation to get the data in the app. Add a backend directory, and create backend_data.dart in it. For a reminder on parsing Json, read How to parse JSON in Dart/Flutter.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import 'dart:async'; import 'dart:convert'; import 'package:widgettests/list/model.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:widgettests/list/list_bloc.dart'; class BackendData implements ItemsDataProvider { static final BackendData _singleton = new BackendData._internal(); factory BackendData() { return _singleton; } BackendData._internal(); @override Future<ListOfItems> loadItems() async { try { final parsed = List<dynamic>.from(json.decode(await rootBundle.loadString('assets/data.json'))['items']); final list = parsed.map((json) => Item.fromJson(json)).toList(); return ListOfItems(list, null); } catch (exception) { // Do not display an exception directly to user in a real app, it's not a user friendly error message! return ListOfItems(null, exception.toString()); } } } |
Let’s amend list_bloc.dart to load the data.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class ListBloc { [...] Future loadItems() async { ListOfItems items = await provider.loadItems(); if (items.items != null) { items.items.sort(_alphabetiseItemsByTitleIgnoreCases); } _itemsController.sink.add(items); } int _alphabetiseItemsByTitleIgnoreCases(Item a, Item b) { return a.title.toLowerCase().compareTo(b.title.toLowerCase()); } } |
UI
And finally, we can build ListPage to use the stream. Depending on the state of the stream data, we either display a CircularProgressIndicator, an error message, or a list of items. When tapping on an item, the app goes to DetailsPage. If an item is selected, the background colour is green.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
import 'package:widgettests/list/list_bloc.dart'; import 'package:widgettests/details/details_page.dart'; import 'package:widgettests/list/model.dart'; [...] class _ListPageState extends State<ListPage> { @override void initState() { super.initState(); ListBloc().loadItems(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(LIST_PAGE_TITLE), ), body: StreamBuilder<ListOfItems>( stream: ListBloc().outItems, initialData: null, builder: (BuildContext context, AsyncSnapshot<ListOfItems> snapshot) { if (snapshot.hasError) { return _displayErrorMessage(snapshot.error.toString()); } else if (snapshot.data == null) { return Center(child: CircularProgressIndicator()); } else if (snapshot.data.errorMessage != null){ return _displayErrorMessage(snapshot.data.errorMessage); } else { return ListView( scrollDirection: Axis.vertical, shrinkWrap: true, children: snapshot.data.items.map((Item value) { return _buildListRow(value); }).toList(), ); } }, ) ); } Widget _displayErrorMessage(String errorMessage) { return Container(padding: EdgeInsets.all(16.0),child: Center(child: Text('Error: $errorMessage'))); } Widget _buildListRow(Item item) { return Container( color: item.selected?Colors.green.shade200:Colors.white, child: ListTile( title: Text(item.title, style: TextStyle(fontWeight: FontWeight.bold),), onTap: () { _displayDetails(item); }, ) ); } void _displayDetails(Item item) async { await Navigator.of(context).push( new MaterialPageRoute<Null>( builder: (BuildContext context) { return DetailsPage(id: item.id); }, ) ); } } |
Displaying the details page
Similar to the setup for ListBloc, we set up a singleton DetailsBloc. Due to the UI of the app, there is only one DetailsPage at any time. Therefore, we can use a singleton Bloc. If there could be several instances of DetailsPage in the widget tree at a given time, we would need to add the item id as a parameter to the Bloc class. DetailsBloc uses a stream of Item.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import 'package:rxdart/rxdart.dart'; import 'package:widgettests/list/model.dart'; class DetailsBloc { static final DetailsBloc _singleton = new DetailsBloc._internal(); factory DetailsBloc() { return _singleton; } DetailsBloc._internal(); BehaviorSubject<Item> _itemController = BehaviorSubject<Item>(); Stream<Item> get outItem => _itemController.stream; void dispose() { _itemController.close(); } } |
DetailsPage knows the id of the item, so let’s implement a method to get the Item from the Bloc, by id.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import 'dart:async'; import 'package:widgettests/list/list_bloc.dart'; [...] class DetailsBloc { [...] StreamSubscription _subscription; int _currentId; void getItem(int id) async { // Reset the item _itemController.sink.add(null); _currentId = id; if (_subscription != null) { _subscription.cancel(); } _subscription = ListBloc().outItems.listen((listOfItems) async { for (var item in listOfItems.items){ if (item.id == _currentId) { _itemController.sink.add(item); break; } } }); } void dispose() { if (_subscription != null) { _subscription.cancel(); } _itemController.close(); } } |
We have everything in place to amend DetailsPage now. The app bar shows the name of the item. Below it, the title is shown, along with ” – Selected” if selected. Below that, a description is shown. Finally, there will be a button to select/unselect an item, which we will implement in the next section.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
import 'package:widgettests/details/details_bloc.dart'; import 'package:widgettests/list/model.dart'; [...] class _DetailsPageState extends State<DetailsPage> { @override void initState() { super.initState(); DetailsBloc().getItem(widget.id); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: StreamBuilder<Item>( stream: DetailsBloc().outItem, initialData: null, builder: (BuildContext context, AsyncSnapshot<Item> snapshot) { if (snapshot.data == null) { return Center(child: CircularProgressIndicator()); } else { return Text(snapshot.data.title); } }, ), ), body: StreamBuilder<Item>( stream: DetailsBloc().outItem, initialData: null, builder: (BuildContext context, AsyncSnapshot<Item> snapshot) { if (snapshot.data == null) { return Center(child: CircularProgressIndicator()); } else { return _buildDetailsView(snapshot.data); } }, ), ); } Widget _buildDetailsView(Item item) { return Container( padding: EdgeInsets.all(16.0), child: Column( children: <Widget>[ Text(item.selected?getSelectedTitle(item.title):item.title, style: TextStyle(fontWeight: FontWeight.bold),), Text(item.description), Text("Button to select/deselect not implemented") ], ), ); } } |
And let’s not forget to dispose of our bloc when the app is finished, in main.dart.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import 'package:widgettests/details/details_bloc.dart'; [...] class _MyAppState extends State<MyApp> { [...] @override void dispose() { ListBloc().dispose(); DetailsBloc().dispose(); super.dispose(); } } |
Handling the user interactions on details page
The two actions are selecting and deselecting an item. This is done on DetailsPage, by tapping a button.
We have setup DetailsBloc to listen to the stream from ListBloc, and update its own stream when there is an update to the current item. Therefore, to select/deselect an item, all we need is to handle select/deselect in ListBloc() – the result will be reflected in ListPage and in DetailsPage.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import 'dart:async'; [...] class ListBloc { [...] void selectItem(int id) { StreamSubscription subscription; subscription = ListBloc().outItems.listen((listOfItems) async { List<Item> newList = List<Item>(); for (var item in listOfItems.items){ if (item.id == id) { newList.add(Item(item.id, item.title, item.description, true)); } else { newList.add(item); } } _itemsController.sink.add(ListOfItems(newList, null)); subscription.cancel(); }); } void deSelectItem(int id) { StreamSubscription subscription; subscription = ListBloc().outItems.listen((listOfItems) async { List<Item> newList = List<Item>(); for (var item in listOfItems.items){ if (item.id == id) { newList.add(Item(item.id, item.title, item.description, false)); } else { newList.add(item); } } _itemsController.sink.add(ListOfItems(newList, null)); subscription.cancel(); }); } [...] } [...] |
We can now configure a button in DetailsPage to actually enable that functionality.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import 'package:widgettests/list/list_bloc.dart'; [...] class _DetailsPageState extends State<DetailsPage> { [...] @override Widget build(BuildContext context) { [...] } Widget _buildDetailsView(Item item) { Widget button = item.selected? RaisedButton( child: Text(REMOVE_BUTTON), onPressed: () => ListBloc().deSelectItem(widget.id) ): RaisedButton( child: Text(SELECT_BUTTON), onPressed: () => ListBloc().selectItem(widget.id), ); return Container( padding: EdgeInsets.all(16.0), child: Column( children: <Widget>[ Text(item.selected?getSelectedTitle(item.title):item.title, style: TextStyle(fontWeight: FontWeight.bold),), Text(item.description), button ], ), ); } } |
Unit testing the blocs
ListBloc contains quite a bit of logic. It loads data from an ItemsDataProvider, sort the entries, selects, and deselects items. So we can write a few unit tests for it.
In test folder, remove the default test file created with the project (widget_test.dart). We start with setting up data providers for tests. Create a backend folder, with test_data_provider.dart and test_data_provider_error.dart, as below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import 'package:widgettests/list/model.dart'; import 'package:widgettests/list/list_bloc.dart'; const String ITEM_TITLE_ALPHA_1 = "an item"; const String ITEM_TITLE_ALPHA_2 = "Before"; const String ITEM_TITLE_ALPHA_3 = "last"; const int ITEM_ID_ALPHA_1 = 2; const int ITEM_ID_ALPHA_2 = 1; const int ITEM_ID_ALPHA_3 = 3; const bool ITEM_SELECTED_FALSE_ALPHA_1 = false; const bool ITEM_SELECTED_TRUE_ALPHA_2 = true; const bool ITEM_SELECTED_FALSE_ALPHA_3 = false; class TestDataProvider implements ItemsDataProvider { @override Future<ListOfItems> loadItems() { List<Item> list = List<Item>(); list.add(Item(ITEM_ID_ALPHA_2, ITEM_TITLE_ALPHA_2, "", ITEM_SELECTED_TRUE_ALPHA_2)); list.add(Item(ITEM_ID_ALPHA_1, ITEM_TITLE_ALPHA_1, "", ITEM_SELECTED_FALSE_ALPHA_1)); list.add(Item(ITEM_ID_ALPHA_3, ITEM_TITLE_ALPHA_3, "", ITEM_SELECTED_FALSE_ALPHA_3)); return Future.value(ListOfItems(list, null)); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import 'package:widgettests/list/list_bloc.dart'; import 'package:widgettests/list/model.dart'; const String ERROR_MESSAGE = "This is an error message"; class TestDataProviderError implements ItemsDataProvider { @override Future<ListOfItems> loadItems() { return Future.value(ListOfItems(null, ERROR_MESSAGE)); } } |
Let’s write our unit tests for ListBloc now. We create a new folder, list, then list_bloc_test.dart as below.
Our first step loads data and verifies the correct data is added to the stream. To verify that, we use the “take” method and convert it to a list. We can pass a number to take, so we can take more than the last event of the stream (we’ll use that in other tests). The rest of the test is simple: create ListBloc instance, inject test data provider, load items, take last event from stream, verify that event contains the data we expect.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import 'package:flutter_test/flutter_test.dart'; import 'package:widgettests/list/model.dart'; import 'package:widgettests/list/list_bloc.dart'; import '../backend/test_data_provider.dart'; void main() { test('Items are alphabetised, ignoring case', () async { final listBloc = ListBloc(); listBloc.injectDataProviderForTest(TestDataProvider()); listBloc.loadItems(); var events = await listBloc.outItems.take(1).toList(); verifyTestData(events[0]); }); } void verifyTestData(ListOfItems data) { verifyTestDataExceptSelected(data); verifySelectedStatus(data.items.elementAt(0), ITEM_SELECTED_FALSE_ALPHA_1); verifySelectedStatus(data.items.elementAt(1), ITEM_SELECTED_TRUE_ALPHA_2); verifySelectedStatus(data.items.elementAt(2), ITEM_SELECTED_FALSE_ALPHA_3); } void verifyTestDataExceptSelected(ListOfItems data) { expect(data.errorMessage, isNull); expect(data.items.length, equals(3)); expect(data.items.elementAt(0).title, equals(ITEM_TITLE_ALPHA_1)); expect(data.items.elementAt(1).title, equals(ITEM_TITLE_ALPHA_2)); expect(data.items.elementAt(2).title, equals(ITEM_TITLE_ALPHA_3)); expect(data.items.elementAt(0).id, equals(ITEM_ID_ALPHA_1)); expect(data.items.elementAt(1).id, equals(ITEM_ID_ALPHA_2)); expect(data.items.elementAt(2).id, equals(ITEM_ID_ALPHA_3)); } void verifySelectedStatus(Item data, bool shouldBeSelected) { expect(data.selected, equals(shouldBeSelected)); } |
We can now write some tests around selecting and deselecting items. For those, we take the last 2 events of the stream, ie the ListOfItems before the selection change, and the one after the selection change.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
[...] void main() { [...] test('Selecting an unselected item updates the stream', () async { final listBloc = ListBloc(); listBloc.injectDataProviderForTest(TestDataProvider()); await listBloc.loadItems(); listBloc.selectItem(ITEM_ID_ALPHA_1); var events = await listBloc.outItems.take(2).toList(); verifyTestData(events[0]); verifyTestDataExceptSelected(events[1]); verifySelectedStatus(events[1].items.elementAt(0), true); verifySelectedStatus(events[1].items.elementAt(1), ITEM_SELECTED_TRUE_ALPHA_2); verifySelectedStatus(events[1].items.elementAt(2), ITEM_SELECTED_FALSE_ALPHA_3); }); test('Selecting a selected item updates the stream', () async { final listBloc = ListBloc(); listBloc.injectDataProviderForTest(TestDataProvider()); await listBloc.loadItems(); listBloc.selectItem(ITEM_ID_ALPHA_2); var events = await listBloc.outItems.take(2).toList(); verifyTestData(events[0]); verifyTestDataExceptSelected(events[1]); verifySelectedStatus(events[1].items.elementAt(0), ITEM_SELECTED_FALSE_ALPHA_1); verifySelectedStatus(events[1].items.elementAt(1), true); verifySelectedStatus(events[1].items.elementAt(2), ITEM_SELECTED_FALSE_ALPHA_3); }); test('Unselecting a selected item updates the stream', () async { final listBloc = ListBloc(); listBloc.injectDataProviderForTest(TestDataProvider()); await listBloc.loadItems(); listBloc.deSelectItem(ITEM_ID_ALPHA_2); var events = await listBloc.outItems.take(2).toList(); verifyTestData(events[0]); verifyTestDataExceptSelected(events[1]); verifySelectedStatus(events[1].items.elementAt(0), ITEM_SELECTED_FALSE_ALPHA_1); verifySelectedStatus(events[1].items.elementAt(1), false); verifySelectedStatus(events[1].items.elementAt(2), ITEM_SELECTED_FALSE_ALPHA_3); }); test('Unselecting an unselected item updates the stream', () async { final listBloc = ListBloc(); listBloc.injectDataProviderForTest(TestDataProvider()); await listBloc.loadItems(); listBloc.deSelectItem(ITEM_ID_ALPHA_1); var events = await listBloc.outItems.take(2).toList(); verifyTestData(events[0]); verifyTestDataExceptSelected(events[1]); verifySelectedStatus(events[1].items.elementAt(0), false); verifySelectedStatus(events[1].items.elementAt(1), ITEM_SELECTED_TRUE_ALPHA_2); verifySelectedStatus(events[1].items.elementAt(2), ITEM_SELECTED_FALSE_ALPHA_3); }); } [...] |
Testing ListPage
Now, we want some UI tests, but we would like them to run fast. Widget tests to the rescue!!!
Setting up a widget test is quite simple, though a little bit of boilerplate code is required. We wrap the widget under test inside MaterialApp, and “pump” that widget. Let’s look at an example, checking the items are displayed, by creating a new file list_page_test.dart, under list folder.
The set up we follow is: set up ListBloc with test data provider, create and launch ListPageWrapper, pump the widget again (more on that later), define some items to find by text and find them, define some predicates for more complicated way to find items (ie Container background colour) and find them.
As with most UI tests, it’s not trivial to check the order of an item in a list but we have tested that with the ListBloc unit test. What we do here is simply using 2 unselected and 1 selected items, so we can verify the background colour of the selected widget by number.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:widgettests/list/list_bloc.dart'; import 'package:widgettests/list/list_page.dart'; import '../backend/test_data_provider.dart'; import '../backend/test_data_provider_error.dart'; void main() { testWidgets('Items are displayed', (WidgetTester tester) async { // Inject data provider ListBloc().injectDataProviderForTest(TestDataProvider()); // Build widget await tester.pumpWidget(ListPageWrapper()); // This causes the stream builder to get the data await tester.pump(Duration.zero); final item1Finder = find.text(ITEM_TITLE_ALPHA_1); final item2Finder = find.text(ITEM_TITLE_ALPHA_2); final item3Finder = find.text(ITEM_TITLE_ALPHA_3); expect(item1Finder, findsOneWidget); expect(item2Finder, findsOneWidget); expect(item3Finder, findsOneWidget); // Under the hood, Container uses BoxDecoration when setting color WidgetPredicate widgetSelectedPredicate = (Widget widget) => widget is Container && widget.decoration == BoxDecoration(color: Colors.green.shade200); WidgetPredicate widgetUnselectedPredicate = (Widget widget) => widget is Container && widget.decoration == BoxDecoration(color: Colors.white); expect(find.byWidgetPredicate(widgetSelectedPredicate), findsOneWidget); expect(find.byWidgetPredicate(widgetUnselectedPredicate), findsNWidgets(2)); }); } class ListPageWrapper extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', home: ListPage(), ); } } |
What other widget tests could we write? Error message, and that the widget is updated when the stream is updated. The first one is simple, a copy of the test above but with a simple error message verification. The second one is a bit more complicated, but it’s basically doing the same thing twice, not forgetting to change the data provider for the second time (so the data effectively changes).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
[...] void main() { [...] testWidgets('Error message is displayed when server error', (WidgetTester tester) async { ListBloc().injectDataProviderForTest(TestDataProviderError()); await tester.pumpWidget(ListPageWrapper()); await tester.pump(Duration.zero); final errorFinder = find.text("Error: " + ERROR_MESSAGE); expect(errorFinder, findsOneWidget); }); testWidgets('Widget is updated when stream is updated', (WidgetTester tester) async { ListBloc().injectDataProviderForTest(TestDataProviderError()); await tester.pumpWidget(ListPageWrapper()); await tester.pump(Duration.zero); final errorFinder = find.text("Error: " + ERROR_MESSAGE); expect(errorFinder, findsOneWidget); ListBloc().injectDataProviderForTest(TestDataProvider()); await ListBloc().loadItems(); await tester.pump(Duration.zero); final item1Finder = find.text(ITEM_TITLE_ALPHA_1); final item2Finder = find.text(ITEM_TITLE_ALPHA_2); final item3Finder = find.text(ITEM_TITLE_ALPHA_3); expect(item1Finder, findsOneWidget); expect(item2Finder, findsOneWidget); expect(item3Finder, findsOneWidget); }); } |
At this point, you may ask yourself this question: What is this widget pumping thing?
tester.pump
This is basically a way to trigger a frame redraw for the widget. That’s a bit weird at first, but I got used to it quickly. The test expects the UI should be redrawn, so it says “let’s see what it looks like when we redraw it”.
Testing DetailsPage
Now, we can write tests for DetailsPage, related to the selected/unselected state. This is when widget tests get very interesting, because you can do some UI actions, such as tap on a button.
First, we’ll write tests to confirm a selected item is shown as selected, and its mirror test, an unselected item is shown as unselected. Let’s create details/details_page_test.dart.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:widgettests/details/details_page.dart'; import 'package:widgettests/list/list_bloc.dart'; import 'package:widgettests/strings.dart'; import '../backend/test_data_provider.dart'; void main() { testWidgets('Selected item is shown as selected', (WidgetTester tester) async { ListBloc().injectDataProviderForTest(TestDataProvider()); await ListBloc().loadItems(); await tester.pumpWidget(DetailsPageSelectedWrapper()); await tester.pump(Duration.zero); final titleFinder = _getSelectedTitleFinder(ITEM_TITLE_ALPHA_2); final pageTitleFinder = _getTitleFinder(ITEM_TITLE_ALPHA_2); final buttonFinder = _getRemoveButtonFinder(); expect(titleFinder, findsOneWidget); expect(pageTitleFinder, findsOneWidget); expect(buttonFinder, findsOneWidget); }); testWidgets('Unselected item is shown as unselected', (WidgetTester tester) async { ListBloc().injectDataProviderForTest(TestDataProvider()); await ListBloc().loadItems(); await tester.pumpWidget(DetailsPageUnselectedWrapper()); await tester.pump(Duration.zero); final titleFinder = _getTitleFinder(ITEM_TITLE_ALPHA_1); final buttonFinder = _getSelectButtonFinder(); expect(titleFinder, findsNWidgets(2)); expect(buttonFinder, findsOneWidget); }); } Finder _getSelectButtonFinder() { return find.text(SELECT_BUTTON); } Finder _getRemoveButtonFinder() { return find.text(REMOVE_BUTTON); } Finder _getTitleFinder(String title) { return find.text(title); } Finder _getSelectedTitleFinder(String title) { return find.text(getSelectedTitle(title)); } class DetailsPageUnselectedWrapper extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', home: DetailsPage(id: ITEM_ID_ALPHA_1), ); } } class DetailsPageSelectedWrapper extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', home: DetailsPage(id: ITEM_ID_ALPHA_2), ); } } |
Now, we can write tests that select an unselected item, and vice versa. Note that, as explained above, those tests use test.pump after tapping on the button.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
[...] void main() { [...] testWidgets('Select unselected item updates widget and stream', (WidgetTester tester) async { ListBloc().injectDataProviderForTest(TestDataProvider()); await ListBloc().loadItems(); await tester.pumpWidget(DetailsPageUnselectedWrapper()); await tester.pump(Duration.zero); final buttonFinder = _getSelectButtonFinder(); await tester.tap(buttonFinder); await tester.pump(Duration.zero); final titleFinder = _getSelectedTitleFinder(ITEM_TITLE_ALPHA_1); final pageTitleFinder = _getTitleFinder(ITEM_TITLE_ALPHA_1); final buttonFinder2 = _getRemoveButtonFinder(); expect(titleFinder, findsOneWidget); expect(pageTitleFinder, findsOneWidget); expect(buttonFinder2, findsOneWidget); }); testWidgets('Unselect selected item updates widget and stream', (WidgetTester tester) async { ListBloc().injectDataProviderForTest(TestDataProvider()); await ListBloc().loadItems(); await tester.pumpWidget(DetailsPageSelectedWrapper()); await tester.pump(Duration.zero); final buttonFinder = _getRemoveButtonFinder(); await tester.tap(buttonFinder); await tester.pump(Duration.zero); final titleFinder = _getTitleFinder(ITEM_TITLE_ALPHA_2); final buttonFinder2 = _getSelectButtonFinder(); expect(titleFinder, findsNWidgets(2)); expect(buttonFinder2, findsOneWidget); }); } |
What next?
In this code tutorial (source code), we were able to write tests that verify not only the logic but also the UI interaction of our app. And those tests took just under 1 second to run (on my machine). That’s great news!
There is, of course, still a place for integration tests, so I encourage you to try them out. But having a category of tests in between unit and integration tests is simply awesome 🙂

Mobile app developer with 12 years experience. Started with the Android SDK in 2010 and switched to Flutter in 2017. Past apps range from start ups to large tech (Google) and non tech (British Airways) companies, in various sectors (transport, commercial operations, e-commerce).