Flutter: A deep dive into the integration_test library

Recently the Flutter team released a new way to do integration testing called the integration_test package.

In this post we’ll look at some reasons why you should use the new package, explain how to use it, and provide some links to the best sources of documentation and examples.

Note: All code for this post can be viewed in its complete form here: https://github.com/gskinnerTeam/flutter-integration-test-examples

Some history: flutter_driver vs integration_test

The old integration test method was something called flutter_driver. While flutter_driver provides all you need to test a simple app or component, it has major limitations when it comes to a real application:

  • you can not easily verify the state of your application
  • it is hard to catch exceptions that occur within your application
  • can not easily interact with app, like showDialog or showBottomSheet
  • the api was overly verbose and had poor readability
  • it requires an external dart bootstrapper to run (not standalone)

integration_test solves all of these issues:

  • you can easily access your app state which can be a more robust way to verify the success of your tests, as opposed to using on-screen labels or string-based ValueKey‘s which can break tests easily
  • interacting with the app to call showDialog etc is simple
  • any exceptions in your widget will immediately fail the test
  • the API is cleaner and more readable

Ok, enough of the sales pitch, let’s take a look at how to use it!

Initial Setup

The following is a quick run-through of the core components. For a full working example, checkout: https://github.com/gskinnerTeam/flutter-integration-test-examples

Step 1) Add to your pubspec.yaml:

dev_dependencies:
  integration_test:
    sdk: flutter

Step 2) Create a folder structure like:

/integration_test
  /smoke_test.dart

Note: It is important that the parent folder be named exactly integration_test. This is primarily to ensure the tests run properly on Android and iOS device farms.

Step 3) Inside of smoke_test.dart:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() async {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  WidgetsFlutterBinding.ensureInitialized();
  testWidgets('Smoke test', (WidgetTester tester) async {
    await tester.pumpWidget(MyApp()); // Create main app
    await tester.pumpAndSettle(); // Finish animations and scheduled microtasks
    await tester.pump(Duration(seconds: 2)); // Render another frame in 2s
  });
}

Step 4) From terminal, run:

flutter test -d windows integration_test/smoke_test.dart
OR
flutter test -d macos integration_test/smoke_test.dart

If all goes well, your app should start up, sit around for a couple seconds, and shut down.

Now you’re ready to write some tests!

Basic Testing

Before you can interact with your app, you must first find something to interact with.

To do this you use the find.byX methods. These include byKey, byText, byIcon, byType and byTooltip among others. You can view a full listing in the CommonFinders class docs: https://api.flutter.dev/flutter/flutter_test/CommonFinders-class.html

As one example, you could look up a textfield and a button using a ValueKey you have assigned to them in the widget tree:

Finder userText = find.byKey(ValueKey('userText'));
Finder loginBtn = find.byKey(ValueKey('loginBtn'));

Once you have a Finder, you can pass it to tester to interact with the UI.

// Enter text
await tester.enterText(userText, 'test@test.com');
// Tap a button
await tester.tap(loginBtn, warnIfMissed: true);

To render frames you can use pump() which renders a single frame with an optional delay, or pumpAndSettle which renders multiple frames until all animations and microtasks are complete. Here we will let the animations finish, and then wait for an additional 2 seconds:

await tester.pumpAndSettle();
await tester.pump(Duration(seconds: 2));

The final thing you’ll need to do for most tests is check some conditions, you can do that with the expect(val1, val2) API. You can check that any 2 objects match, including simple bools or even check for the existence of widgets using some predefined Matcher constants like findsOneWidget:

// Ensure there is a login and password field on the initial page
expect(userText, findsOneWidget);
expect(passText, findsOneWidget);

The integration_test package comes with a bunch of these premade Matcher constants and they are very useful for writing readable tests. For a full list, search const Matcher within the flutter_test docs: https://api.flutter.dev/flutter/flutter_test/flutter_test-library.html.

You can of course also match on basic primitives. Here we’ll check whether all animations have stopped running:

expect(SchedulerBinding.instance!.transientCallbackCount, 0);

Accessing state

Verifying your app state is simple, just look it up by type:

MyAppState state = tester.state(find.byType(MyApp));
expect(state.isLoggedInState.value, true);

In this case isLoggedInState is just a ValueNotifier that exists on a StatefulWidget called MyApp. You could also lookup an ancestor widget if using Provider or something similar:

MyScaffoldState state = tester.state(find.byType(MyScaffold));
UserModel model = Provider.of<UserModel>(state.currentContext);
expect(model.isLoggedIn, true);

Controlling Navigator

Because you can easily access state of any Widget, controlling the navigator becomes trivial.

Assign a GlobalKey to your MaterialApp.navigatorKey, and then find and use it from your tests:

// Get a State that has a reference to the navKey
MyAppState state = tester.state(find.byType(MyApp));

// Use navKey to get current navigator
NavigatorState navigator = state.navKey.currentState!;

// Show a dialog
showDialog(
  context: navigator.context,
  builder: (c) => _SomeDialog(),
);

// Close dialog, method 1
navigator.pop();
await tester.pumpAndSettle(); // let dialog animate away

// Close dialog, method 2
await tester.tap(find.byKey(ValueKey('okBtn')));
await tester.pumpAndSettle(); // let dialog animate away

// Verify dialog was closed
expect(find.byType(_SomeDialog), findsNothing);

This same approach can be applied to bottom sheets and any other type of Overlay content.

With all that done, we now have an app that boots up, enters login credentials, logs in, and tests our basic dialog system:

Not bad for a few lines of code!

Where to Learn More

There are three primary resources to learn more about this API:

A good workflow we’ve found is to find API within WidgetTester that look useful, then search the flutter repo for usage examples. For example, if you want to see how WidgetTester.state actually works, you could search for: https://github.com/flutter/flutter/search?q=tester.state, or, to see examples of using findsNothing, look for https://github.com/flutter/flutter/search?q=findsNothing.

WidgetTester can drive too many behaviors to fully cover here, but some of the more useful ones are:

Interaction

  • press – Dispatch a pointer down at the center of the given widget,
  • tap – Dispatch a pointer down / pointer up sequence at the center of the given widget
  • fling / flingFrom – Attempts a fling gesture from a start and end offset
  • drag / dragFrom – Attempts a drag gesture from a start and end offset
  • longPress– Dispatch a pointer down / up sequence with a delay
  • enterText – Give a text input focus and fill it with text
  • ensureVisible – Attempts to scroll a Widget into view
  • dragUntilVisible – Repeatedly drags a view until a Widget is visible
  • sendKeyEvent – Simulates a physical key up event

Rendering

  • pump – Triggers a frame render after duration amount of time
  • pumpAndSettle – Triggers multiple frame renders, lets animations and scheduled tasks complete

Widgets

  • getRect – Returns the rect of the given widget
  • getSize – Returns the size of the given widget
  • getSemantics – Returns the closest semantics node ancestor
  • firstWidget – Returns the first matching widget

State

  • allStates – All states currently in the widget tree
  • state – Returns one matching state
  • firstState – Returns the first matching state
  • stateList – All matching states current in the widget tree

For a full listing dive into the WidgetTester docs here: https://api.flutter.dev/flutter/flutter_test/WidgetTester-class.html.

To run this example for yourself, just check out the repo mentioned at the top of the post: https://github.com/gskinnerTeam/flutter-integration-test-examples.

Hopefully this helps get you started with the new integration_testing package. In a future post we’ll take a look at how we can integrate this test into GitHub actions and get a continuous test cycle going (hint: it’s super easy!).


Need help building something cool in Flutter? We’d love to chat.

shawn.blais

Shawn has worked as programmer and product designer for over 20 years, shipping several games on Steam and Playstation and dozens of apps on mobile. He has extensive programming experience with Dart, C#, ActionScript, SQL, PHP and Javascript and a deep proficiency with motion graphics and UX design. Shawn is currently the Technical Director for gskinner.

@tree_fortress

2 Comments

  1. Thanks for this detailed integration insight Shawn.

  2. Thanks, this is inspiring! Do you know if there is a method to restart the app within a single test?

Leave a Reply

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