Testing Flutter Apps
Introduction
The more features your app has, the harder it is to test it manually. A good set of automated tests will help you make sure your app performs correctly before you publish it while retaining your feature and bug fix velocity.
There are many kinds of automated testing. These are summarized below:
- A unit test tests a single function, method, or class. External dependencies
of the unit under test are generally mocked out using, for example,
package:mockito
. Unit tests generally do not read from/write to disk, render to screen and do not receive user actions from outside the process running the test. The goal of a unit test is to verify the correctness of a unit of logic under a variety of conditions. - A widget test (in other UI frameworks referred to as component test) tests a single widget. Testing a widget involves multiple classes and requires a test environment that provides the appropriate widget lifecycle context. For example, it should be able to receive and respond to user actions and events, perform layout, and instantiate child widgets. A widget test is therefore more comprehensive than a unit test. However, like a unit test, a widget test’s environment is replaced with an implementation much simpler than a full-blown UI system. The goal of a widget test is to verify that the widget’s UI looks and interacts as expected.
- An integration test tests a complete app or a large part of an app. Generally, an integration test runs on a real device or an OS emulator, such as iOS Simulator or Android Emulator. The app under test is typically isolated from the test driver code to avoid skewing the results. The goal of an integration test is to verify that the app functions correctly as a whole, that all the widgets it is composed of integrate with each other as expected. You can also use your integration tests to verify your app’s performance.
Here is a table summarizing the tradeoffs concerning the choice between different kinds of tests:
Unit | Widget | Integration | |
---|---|---|---|
Confidence | Low | Higher | Highest |
Maintenance cost | Low | Higher | Highest |
Dependencies | Few | More | Lots |
Execution speed | Quick | Slower | Slowest |
Tip: As a rule of thumb a well-tested app has a very high number of unit and widget tests, tracked by code coverage, and a good number of integration tests covering all the important usage scenarios.
Unit testing
Some Flutter libraries, such as dart:ui
, are not available in the standalone
Dart VM that ships with the default Dart SDK. The flutter test
command lets
you run your tests in a local Dart VM with a headless version of the Flutter
Engine, which supplies these libraries. Using this command you can run any test,
whether it depends on Flutter libraries or not.
Write a Flutter unit test as a normal package:test
test. Writing unit tests
using package:test
is documented here.
Example:
Add this file to test/unit_test.dart
:
import 'package:test/test.dart';
void main() {
test('my first unit test', () {
var answer = 42;
expect(answer, 42);
});
}
In addition, you must add the following block to your pubspec.yaml
:
dev_dependencies:
flutter_test:
sdk: flutter
(This is needed even if your test does not itself explicitly import
flutter_test
, because the test framework itself uses it behind the
scenes.)
To run the test, run flutter test test/unit_test.dart
from your
project directory (not from the test
subdirectory).
To run all your tests, run flutter test
from your project directory.
Widget testing
You implement a widget test in a similar way as a unit test. To perform an
interaction with a widget in your test, use the
WidgetTester
utility that Flutter provides. For example, you can send tap and scroll
gestures. You can also use
WidgetTester
to find child widgets in the widget tree, read text, and verify that the values
of widget properties are correct.
Example:
Add this file to test/widget_test.dart
:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('my first widget test', (WidgetTester tester) async {
// You can use keys to locate the widget you need to test
var sliderKey = new UniqueKey();
var value = 0.0;
// Tells the tester to build a UI based on the widget tree passed to it
await tester.pumpWidget(
new StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return new MaterialApp(
home: new Material(
child: new Center(
child: new Slider(
key: sliderKey,
value: value,
onChanged: (double newValue) {
setState(() {
value = newValue;
});
},
),
),
),
);
},
),
);
expect(value, equals(0.0));
// Taps on the widget found by key
await tester.tap(find.byKey(sliderKey));
// Verifies that the widget updated the value correctly
expect(value, equals(0.5));
});
}
Run flutter test test/widget_test.dart
.
Check out package:flutter_test
API
for all the utilities available for widget testing.
To help debug widget tests, you can use the debugDumpApp()
function to visualize the UI state of your test or simply flutter run test/widget_test.dart
to see your test run in your preferred runtime environment such as a simulator or a device. During a flutter run
session on a widget test, you can also interactively tap parts of the screen for the Flutter tool to print the suggested Finder
.
Integration testing
A Flutter integration test is also written using package:test
. A full test is a
pair - a test script and a Flutter app instrumented to receive commands
from the test. Unlike unit and widget tests, integration test code does not run
in the same process as the app that’s being tested. Instead, the tested
app is launched on a real device or in an emulator (e.g. Android
Emulator or iOS Simulator). The test script runs on your computer. It connects
to the app and issues commands to the app to perform various
user actions. This is known as “driving” the app. Flutter provides tools
and APIs, collectively referred to as Flutter Driver, to do just that.
If you are familiar with Selenium/WebDriver (web), Espresso (Android) or UI Automation (iOS), then Flutter Driver is Flutter’s equivalent to those integration testing tools. In addition, Flutter Driver provides API for recording performance traces (a.k.a. the timeline) from actions performed by the test.
Flutter Driver is:
- a command-line tool
flutter drive
- a package
package:flutter_driver
(API)
Together, the two allow you to:
- create instrumented app for integration testing
- write a test
- run the test
Adding the flutter_driver dependency
To use flutter_driver
, you must add the following block to your pubspec.yaml
:
dev_dependencies:
flutter_driver:
sdk: flutter
Creating instrumented Flutter apps
An instrumented app is a Flutter app that has the Flutter Driver
extension enabled. To enable the extension call enableFlutterDriverExtension()
.
Example:
Let’s assume you have an app with the entry point in
my_app/lib/main.dart
. To create an instrumented version of it, create a Dart
file under my_app/test_driver/
. Name it after the feature you are testing;
let’s go for user_list_scrolling.dart
located in my_app/test_driver/:
// This line imports the extension
import 'package:flutter_driver/driver_extension.dart';
void main() {
// This line enables the extension
enableFlutterDriverExtension();
// Call the `main()` of your app or call `runApp` with whatever widget
// you are interested in testing.
}
Writing integration tests
An integration test is a plain package:test
test that uses the Flutter Driver
API to tell the app what to do and then verifies that the app
did it.
Example:
Just for fun let’s also make our test record the performance timeline. Let’s
create a test file user_list_scrolling_test.dart
located in my_app/test_driver/
:
import 'dart:async';
// Imports the Flutter Driver API
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
group('scrolling performance test', () {
FlutterDriver driver;
setUpAll(() async {
// Connects to the app
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
if (driver != null) {
// Closes the connection
driver.close();
}
});
test('measure', () async {
// Record the performance timeline of things that happen inside the closure
Timeline timeline = await driver.traceAction(() async {
// Find the scrollable user list
SerializableFinder userList = find.byValueKey('user-list');
// Scroll down 5 times
for (int i = 0; i < 5; i++) {
// Scroll 300 pixels down, for 300 millis
await driver.scroll(
userList, 0.0, -300.0, new Duration(milliseconds: 300));
// Emulate a user's finger taking its time to go back to the original
// position before the next scroll
await new Future<Null>.delayed(new Duration(milliseconds: 500));
}
// Scroll up 5 times
for (int i = 0; i < 5; i++) {
await driver.scroll(
userList, 0.0, 300.0, new Duration(milliseconds: 300));
await new Future<Null>.delayed(new Duration(milliseconds: 500));
}
});
// The `timeline` object contains all the performance data recorded during
// the scrolling session. It can be digested into a handful of useful
// aggregate numbers, such as "average frame build time".
TimelineSummary summary = new TimelineSummary.summarize(timeline);
// The following line saves the timeline summary to a JSON file.
summary.writeSummaryToFile('scrolling_performance', pretty: true);
// The following line saves the raw timeline data as JSON.
summary.writeTimelineToFile('scrolling_performance', pretty: true);
});
});
}
Running integration tests
To run the test on an Android device, connect the device via USB to your computer and enable USB debugging. Then run the following command:
flutter drive --target=my_app/test_driver/user_list_scrolling.dart
This command will:
- build the
--target
app and install it on the device - launch the app
- run the
user_list_scrolling_test.dart
test located inmy_app/test_driver/
You might be wondering how the command finds the correct test file. The
flutter drive
command uses a convention to look for the test file in the same
directory as the instrumented --target
app that has the same file name
but for the _test
suffix in it.