What I Wish Someone Had Told Me About Integration Testing in Flutter

April 21, 2023
developer looking at code behind a screen overlay

Integration testing in Flutter is fairly well documented. The official docs have a get-started guide that will get you started, and it’s not hard to find answers to common issues on blogs or Stack Overflow.

This isn’t going to be a walkthrough of setting up testing Flutter as the docs do a good job of that, or a high-level guide to the basics as those are common. This is a set of stumbling blocks, hard problems, and general things I wish I’d known when I started. It’s exclusively scoped to system tests on Android.

Flutter Tests Do Not Run in Real-Time

Suppose you have a floating icon button that adds a line of text to a list. When you click the button, a line “Hello” appears. Click it three times, see three “Hello” rows.

expect(find.text("Hello"), findsNothing);
await tester.tap(find.byIcon(Icons.add));
expect(find.text("Hello"), findsOneWidget);

This code will fail; finding nothing. That’s because Flutter tests don’t update in real time. await tester.tap(...) queues up the next frame with the new row, but doens’t add it yet. To advance to the next frame and see our output, we need await tester.pump().

expect(find.text("Hello"), findsNothing); await tester.tap(find.byIcon(Icons.add)); await tester.pump(); expect(find.text("Hello"), findsOneWidget);

This code will pass. However, await tester.pump() will only advance a single frame: if your interaction has animations, you will need to call await tester.pumpAndSettle() instead. This is an oversimplification, but it works in practice. There are more tester.pumpAndSomething type methods to be more precise.

Flutter Tests Are Reliant on The Screen Size

Suppose you’ve got lots of lines in your list; enough that the screen is starting to scroll. Let’s presume they have unique keys that you can find with find.byKey(yourKey), and that you want to tap one near the bottom.

await tester.tap(find.byKey(yourKey));

This code will fail to find the element even though it is on the page. Flutter, by default, will refuse to find elements that are off the screen. You can override that refusal with skipOffstage: false, and scroll to it with tester.ensureVisible.

final yourElement = find.byKey(yourKey, skipOffstage: false);
await tester.ensureVisible(yourElement);
await tester.pumpAndSettle();
await tester.tap(yourElement);
await tester.pumpAndSettle();

If the onscreen keyboard is shown for any reason, that will cause elements to be hidden as well, so use FocusManager.instance.primaryFocus?.unfocus() to hide it before doing any ensureVisible.

Items in a Scrollable Don’t Automatically Exist

Scrollables don’t actually render all of their children: they render those onscreen and a few offscreen in either direction, but distant members of a list aren’t rendered at all.

Therefore, if you have a long page with 10 similar elements, even if your code is very clearly looping through every entry in your list and adding widgets, the above example can fail if the matching element hasn’t been rendered.

There’s a different method similar to ensureVisible that will scroll the list until a matching element exists:

final yourElement = find.byKey(yourKey); // Note the lack of skipOffstage
final yourList = find.byKey(yourListKey);
await tester.dragUntilVisible(yourListMember, yourList, const Offset(0, 200));
await tester.pumpAndSettle();
await tester.tap(yourElement);
await tester.pumpAndSettle();

This code will find the element in the ListView, scrolling by 200 units each iteration, until it is rendered and visible, and then tap it. (200 units is entirely arbitrary, but there is a max number of iterations)

Note that unlike ensureVisible, which just needed a finder that allowed offstage elements, dragUntilVisible requires you to specify the ListView directly and give it explicit scrolling instructions.

Flutter’s Tests Do Not Clear the Filesystem

Suppose your tests persist data locally. (see https://docs.flutter.dev/cookbook/persistence/reading-writing-files)

If the rows you add to a list get saved and then automatically re-created when the user re-opens the app, your tests will fail because the data from the previous run will still be there. If you use the same environment for development, you’ll even collide with the data from your last session using the app.

Fortunately, you can clear the data before your tests run. Flutter’s testing framework gives the usual lifecycle callbacks, and you can use those to clean the local filesystem.

void clearYourAppData() {
  // Delete any files/directories you rely on here

void main() {
setUpAll(clearYourAppData); // This runs at the start, before any tests
tearDown(clearYourAppData); // This runs after each test, including at the end

testWidgets(...) async {
  // Now your test can rely on a clean filesystem every time it runs.

This deletion should be as generic and tolerant as possible to cover cases where more or less app state is present than you might expect.

Important Note: more than just your handwritten files exist in the output of getApplicationDocumentsDirectory()! (The directory the docs recommend as a way to get a reliable, writable, place on every platform)
Ensure you delete only the files you want to, either by keeping a list of all possible filenames or by scoping them to a fixed directory and then deleting that directory.

Dropdowns Are Difficult

Suppose you have a DropdownButton widget, with DropdownMenuItem children with text “A”, “B”, and “C”.
To test selecting option C, you might write:

await tester.tap(find.byKey(yourDropdownKey));
await tester.pumpAndSettle();
await tester.tap(find.text("C"));
await tester.pumpAndSettle();

Multiple widgets will be found with text “C”, presumably because of some implementation details of DropdownButton.

await tester.tap(find.byKey(yourDropdownKey));
await tester.pumpAndSettle();
await tester.tap(find.text("C").last); // Note the new .last call here
await tester.pumpAndSettle();

Now the visible, clickable, DropdownButton will be tapped. This behavior isn’t documented, but it appears consistent across platforms.

It’s Hard to Find Specific Widgets

Flutter provides three basic ways to find widgets for testing:

find.text("Your text here");

There are also plenty of ways to use images/icons instead of text. Then two more ways to involve parentage:

find.descendent(of: find.text(...), matching: find.text(...));
find.ancestor(of: find.text(...), matching: find.text(...));

However, that’s pretty much it. There is no out-of-the-box way to implement “Find the Nth widget matching these criteria in a list”, or “Find me a widget matching some criteria but not others”. It is possible to write your own finders, though, so expect to build out a suite of helper methods you import in your tests.

Important note: Be careful assigning key attributes to widgets! It’s not just used for testing; it’s an important part of how Flutter renders your app and responds to changes. Only use a key when the widget is genuinely unique. See https://api.flutter.dev/flutter/foundation/Key-class.html

Integration Tests Don’t Automatically Run on Web

You’ve followed the docs, written your tests, and they run as expected on Android. You can run flutter test integration_test/your_test.dart -d $YOUR_EMULATOR_ID and it works fine, even on CI.
But what about web? As of writing in early 2023, that doesn’t immediately work, but it can be _made_ to work. You’ll need to use Flutter Driver, an older system. See https://docs.flutter.dev/testing/integration-tests#running-in-a-browser. The main changes you’ll need to do are:
1. Call IntegrationTestWidgetsFlutterBinding.ensureInitialized() in your test’s main function
2. Have chromedriver running in the background
3. Run with flutter drive ... instead of flutter test.

Important note: if you use helper files imported relatively (e.g. import '../test_utilities/custom_matchers.dart;') then those helpers need to be in the same directory as your test files! Importing using ../ will not work when running integration tests for Web using flutter driver. Moving your test helpers to a directory inside your integration_test directory such that your import becomes import 'test_utilities/custom_matchers.dart'; will work.

A Few Last Things

To wrap up, here’s a few smaller gotchas:

  • Most examples test use expect(find.text("Something"), findsOneWidget); This expctation will pass if _any_ widget has that text; to test one widget specifically use widget(find.byKey(...)).data to retrieve the text and then expect(thatData, "Your expected text");.
  • find.byType(DropdownMenuItem) does not find anything, even if the dropdown menu is open. This behavior is presumably due to some implementation details, likely related to why it’s necessary to use find.text(...).last when finding dropdown menu items by text.
  • await tester.pageBack() will exercise the app’s Back button when using Navigator.push.
  • When using the Slider widget, testing it is not straightforward. There’s no tester.slide(finder, someValue) function, though you can write one that accomplishes the same thing: https://stackoverflow.com/questions/57196006/selecting-value-in-slider-with-widget-test

That’s all I’ve got! Hopefully, this will be useful; Flutter is an amazing platform, and while it feels like it’s still finding its footing, the foundation is solid, and most things work as best as they can be expected to, given the number of different ways it’s expected to run.

Build awesome things for fun.

Check out our current openings for your chance to make awesome things with creative, curious people.

Explore SEP Careers »

You Might Also Like