Extending Flutter Driver with custom commands

Me 2016 square

Pascal Widdershoven - 17 February 2020
817 words in about 4 minutes

Flutter Driver is a library to write end-to-end integration tests for Flutter apps. It’s similar to Selenium WebDriver (for web apps), Espresso (for native Android apps) and Earl Grey (for native iOS apps). It works by instrumenting the Flutter app, deploying it on a real device or emulator and then ‘driving’ the application using a suite of Dart tests.

A typical, basic Flutter Driver test looks like this:

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
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
  group('Counter App', () {
    final counterTextFinder = find.byValueKey('counter');
    final buttonFinder = find.byValueKey('increment');

    FlutterDriver driver;

    // Connect to the Flutter driver before running any tests.
    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    // Close the connection to the driver after the tests have completed.
    tearDownAll(() async {
      driver?.close();
    });

    test('starts at 0', () async {
      expect(await driver.getText(counterTextFinder), "0");
    });

    test('increments the counter', () async {
      await driver.tap(buttonFinder);
      
      expect(await driver.getText(counterTextFinder), "1");
    });
  });
}

In the setUpAll hook a connection between the test and the running application is setup via the Flutter Driver API. This works because the application is instrumented with a Flutter Driver extension; basically an API injected into your app that can receive requests from our tests to “drive” the application.

The instrumentation of the Flutter app works by wrapping your app’s main function like this:

1
2
3
4
5
6
7
import 'package:flutter_driver/driver_extension.dart';
import 'package:my_app/main.dart' as app;

void main() {
  enableFlutterDriverExtension(); // <-- ENABLE INSTRUMENTATION
  app.main();
}

Flutter Driver supports a handful of API’s to communicate with the running app. For example getText, tap, waitFor etc. For me, coming from Nightwatch.js, the number of things that can be done to drive the application is quite limited.

Fortunately it’s possible to extend Flutter Driver to support custom commands. These commands allow you to communicate between your tests and the application and are also the foundation for all of Flutter Driver’s own API’s like getText, tap etc.

Extending Flutter Driver

To extend Flutter Driver with a custom command we need to provide a DataHandler. As the docs say:

1
2
Optionally you can pass a [DataHandler] callback. It will be called if the
test calls [FlutterDriver.requestData].

Flutter Driver will pass whatever is sent from test with driver.requestData(...) to the DataHandler. DataHandler only supports sending and receiving Strings, so you might want to encode your messages using JSON.

To demonstrate this, let’s implement a handler to navigate back to the root route of our app. This way we can ensure that every test starts from the root page of our application.

The first step is to provide a DataHandler to Flutter Driver:

1
2
3
enableFlutterDriverExtension(handler: (payload) async {
  print(payload);
});

The handler will receive a String payload and can optionally return a String response.

For the sake of simplicity let’s use a String as payload for now:

1
2
3
4
5
enableFlutterDriverExtension(handler: (payload) async {
  if(payload == "navigate_to_root") {
    // do something smart here
  }
});

From here, we need to implement something that will allow us to navigate to the root of our app. I’m not sure if the following is necessarily the best way to do this (if you know a better way please let me know!), but it works and is relatively straightforward.

We’ll use a NavigationObserver to get a hold of the NavigatorState, which we can use to push and pop routes. We need to be able to pass in a NavigationObserver from our test entry point, so we can access it when we receive a command to navigate to root.

Change the main function of your app as follows:

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
void main() {
  _main(null);
}

void mainTest(NavigatorObserver navigatorObserver) {
  _main(navigatorObserver);
}

void _main(NavigatorObserver navigatorObserver) {
  runApp(MyApp(
    navigatorObserver: navigatorObserver,
  ));
}

class MyApp extends StatelessWidget {
  final NavigatorObserver navigatorObserver;

  const MyApp({Key key, this.navigatorObserver}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // all other MaterialApp initialisation here
      navigatorObservers: navigatorObserver == null ? [] : [navigatorObserver],
    );
  }
}

This allows us to hook up a NavigationObserver from our test wrapper like so:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import 'package:flutter/material.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:my_app/main.dart' as app;

void main() {
  final navigationObserver = NavigatorObserver();

  enableFlutterDriverExtension(handler: (payload) async {
    if (payload == "navigate_to_root") {
      navigationObserver.navigator.popUntil(ModalRoute.withName('/'));
    }

    return null;
  });

  app.mainTest(navigationObserver);
}

Now from our tests we can send our custom command, for example in a setUp hook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void main() {
  FlutterDriver driver;

  setUpAll(() async {
    driver = await FlutterDriver.connect();
  });

  tearDownAll(() async {
    driver?.close();
  });

  setUp(() async {
    await driver.requestData("navigate_to_root");
  });	

  /* Actual tests here */
}

This will make sure that before every test, the app navigates back to the root route no matter where we navigated to in our tests.

Of course, this is just an example of how you can implement communication between your Driver tests and your app. If you’re going to send more complex commands that require arguments you might want to send JSON data, but I’ll leave that as an exercise to you, dear reader ;-)

Me 2016 square

Pascal Widdershoven

Full Stack Developer • Github: pascalw • Twitter: @_pascalw

At Kabisa, privacy is of the greatest importance. We think it is important that the data our visitors leave behind is handled with care. For example, you will not find tracking cookies from third parties such as Facebook, Hotjar or Hubspot on our website. Only cookies from Google and Vimeo are used in order to improve the user experience of our visitors. These cookies also ensure that relevant advertisements are displayed. Read more about the use of cookies in our privacy statement.