Using commands to query flutter driver for widgets states and properties

Artur Korobeynyk
5 min readApr 5, 2021

In this story we will use a basic template flutter application to see how flutter driver is used to test that application, what are driver limitations with basic setup and how to go beyound those and unlock all driver capabilities.

First of all we need to create a new project. Run the next command in your terminal:

flutter create beyound_flutter_driver

This will create a basic project with all the necessary files. Navigate into folder with a newly created project and open it with visual code running code . inside it.

Replace lib/main.dart code with this:

import 'package:flutter/material.dart';void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
bool _isButtonDisabled;
void incrementCounter() {
setState(() {
_isButtonDisabled = true;
_counter++;
_isButtonDisabled = false;
});
}
@override
void initState() {
super.initState();
_isButtonDisabled = false;
}
Widget _buildCounterButton() {
return new ElevatedButton(
child: new Text("Increment"),
onPressed: _isButtonDisabled ? null : incrementCounter,
key: ValueKey('increment'),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
key: ValueKey('counter'),
style: Theme.of(context).textTheme.headline4,
),
_buildCounterButton(),
],
),
),
);
}
}

Open the pubspec.yaml file and change its dev_dependencies to:

dev_dependencies:
flutter_driver:
sdk: flutter
test: any

Create new test_driver folder in the project and app.dart file inside it

import 'package:flutter_driver/driver_extension.dart';
import 'package:beyound_flutter_driver/main.dart' as app;
void main() {
enableFlutterDriverExtension();
app.main();
}

Add test_driver/app_test.dart file with content:

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;setUpAll(() async {
driver = await FlutterDriver.connect();
});
setUp(() async {
startingCounter = int.parse(await driver.getText(counterTextFinder));
});
tearDownAll(() async {
if (driver != null) {
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 Android Studio open the AVD Manager (Simulator in iOS) Tools -> AVD Manager, add new android (iOS)device if necessary and start it. To run the tests execute following command:

flutter drive --target=test_driver/app.dart

At this point all tests should pass as there is nothing complicated in this example.

Now lets add another test which will add some more values:

test('increment the counter many times', () async {
for (var i = startingCounter + 1; i < startingCounter + 5; i++) {
await driver.tap(buttonFinder);
expect(await driver.getText(counterTextFinder), i.toString());
}
});

Run tests again and check that everything works fine once again.

Now lets add a bit of real logic simulation. When we press increment button there will be some logic hidden behind it. And while it executes we want our increment button to become disabled so user will not be able to add new value while current is still being added. Change the incrementCounter to:

void incrementCounter() {
setState(() {
_isButtonDisabled = true;
_counter++;
Future.delayed(Duration(seconds: 1)).whenComplete(() => setState(() {
_isButtonDisabled = false;
}));
});
}

Now try to run tests again. You will see that tests are failing. This is happening because we are clicking Increment button while it is disabled. There is no function in flutter driver to wait for enabled button. And many testers at this point start to either:

a) sleep and tap

b) tap and wait for text to appear, if not tap once again

But starting from flutter version 2 it is possible to implement custom commands. So lets make one that is capable to retrieve button state.

First we need to create a custom command description file. Create file lib/custom_commands.dart

import 'package:flutter_driver/flutter_driver.dart';enum ButtonState { enabled, disabled }class GetButtonStateCommand extends CommandWithTarget {
GetButtonStateCommand(SerializableFinder finder, {Duration timeout})
: super(finder, timeout: timeout);
GetButtonStateCommand.deserialize(
Map<String, String> json, DeserializeFinderFactory finderFactory)
: super.deserialize(json, finderFactory);
@override
Map<String, String> serialize() {
return super.serialize();
}
@override
String get kind => 'GetButtonState';
}
class GetButtonStateCommandResult extends Result {
const GetButtonStateCommandResult(this.result);
final ButtonState result;GetButtonStateCommandResult.fromJson(Map<String, dynamic> json)
: this.result = ButtonState.values
.firstWhere((value) => value.index == json['state']);
@override
Map<String, int> toJson() {
return <String, int>{
'state': result.index,
};
}
}

Now we can make implementation of this custom command. Create file lib/custom_commands_extension.dart

Now paste this content into it:

import 'package:flutter/material.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_driver/flutter_driver.dart' show Command, Result;
import 'custom_commands.dart';
class GetButtonStateCommandExtension extends CommandExtension {
@override
String get commandKind => 'GetButtonState';
@override
Future<Result> call(
covariant GetButtonStateCommand command,
WidgetController prober,
CreateFinderFactory finderFactory,
CommandHandlerFactory handlerFactory) async {
final Finder finder = finderFactory.createFinder(command.finder);
// wait until widget is present on the screen
await handlerFactory.waitForElement(finder);
//check that widget is a button
var btn = prober.widget(finder) as ElevatedButton;
return btn.enabled
? GetButtonStateCommandResult(ButtonState.enabled)
: GetButtonStateCommandResult(ButtonState.disabled);
}
@override
Command deserialize(
Map<String, String> params,
DeserializeFinderFactory finderFactory,
DeserializeCommandFactory commandFactory) {
return GetButtonStateCommand.deserialize(params, finderFactory);
}
}

This is very raw implementation but it is done to make the understanding easier. It has some flaws but those are not the point of this tutorial.

Now we need to turn on this command in our tests. Change test_driver/app.dart file to this:

import 'package:flutter_driver/driver_extension.dart';
import 'package:beyound_flutter_driver/custom_commands_extension.dart';
import 'package:beyound_flutter_driver/main.dart' as app;
void main() {
enableFlutterDriverExtension(commands: [GetButtonStateCommandExtension()]);
app.main();
}

By doing this we enabled new command extension in our flutter driver extension so now we can send this command from tests to application. Update tests to match the following:

import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
import 'package:beyound_flutter_driver/custom_commands.dart';
Future<void> waitForButtonState(
FlutterDriver driver, SerializableFinder finder, ButtonState state) async {
var btnState;
while (btnState != state) {
btnState = GetButtonStateCommandResult.fromJson(
await driver.sendCommand(GetButtonStateCommand(finder)),
).result;
await Future.delayed(Duration(milliseconds: 200), () => {});
}
}
void main() {
group('Counter App', () {
final counterTextFinder = find.byValueKey('counter');
final buttonFinder = find.byValueKey('increment');
FlutterDriver driver;
int startingCounter;
setUpAll(() async {
driver = await FlutterDriver.connect();
});
setUp(() async {
startingCounter = int.parse(await driver.getText(counterTextFinder));
});
tearDownAll(() async {
if (driver != null) {
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");
});
test('increment the counter many times', () async {
for (var i = startingCounter + 1; i < startingCounter + 5; i++) {
await waitForButtonState(driver, buttonFinder, ButtonState.enabled);
await driver.tap(buttonFinder);
expect(await driver.getText(counterTextFinder), i.toString());
}
});
});
}

Now if you run test it will pass again and clicks will happen only when button becomes enabled.

In the same way you can query for text fonts, colors, sizes, etc.

--

--