Today I'll show you how to write testable code in Flutter, and take your widget tests to the next level.
Coming from the world of iOS development, I use dependency injection and Swift protocols to write testable code.
Why? So that my tests run faster, in isolation, and without side effects (no access to the network or filesystem).
After reading about unit, widget and integration tests in Flutter, I could not find guidelines about:
- How to create protocols in Dart?
- How to do dependency injection in Dart?
Turns out, protocols are roughly the same as abstract classes.
What about dependency injection? The Flutter docs are not helpful:
Does Flutter come with a dependency injection framework or solution? Not at this time. Please share your ideas at flutter-dev@googlegroups.com.
So, what to do? 🤔
Short Story
- Inject dependencies as abstract classes into your widgets.
- Instrument your tests with mocks and ensure they return immediately.
- Write your expectations against the widgets or your mocks.
Long Story
We'll get into some juicy details. But first, we need a sample app.
Use case: Login form with Firebase authentication
Suppose you want to build a simple login form, like this one:
This works as follows:
- The user can enter her email and password.
- When the Login button is tapped, the form is validated.
- If the email or password are empty, we highlight them in red.
- If both email and password are non-empty, we use them to sign in with Firebase and show a confirmation message.
Here is a sample implementation for this flow:
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
class LoginPage extends StatefulWidget {
LoginPage({Key key, this.title}) : super(key: key);
final String title;
@override
_LoginPageState createState() => new _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
static final formKey = new GlobalKey<FormState>();
String _email;
String _password;
String _authHint = '';
bool validateAndSave() {
final form = formKey.currentState;
if (form.validate()) {
form.save();
return true;
}
return false;
}
void validateAndSubmit() async {
if (validateAndSave()) {
try {
FirebaseUser user = await FirebaseAuth.instance
.signInWithEmailAndPassword(email: _email, password: _password);
setState(() {
_authHint = 'Success\n\nUser id: ${user.uid}';
});
}
catch (e) {
setState(() {
_authHint = 'Sign In Error\n\n${e.toString()}';
});
}
} else {
setState(() {
_authHint = '';
});
}
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(widget.title),
),
body: new Container(
padding: const EdgeInsets.all(16.0),
child: new Form(
key: formKey,
child: new Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
new TextFormField(
key: new Key('email'),
decoration: new InputDecoration(labelText: 'Email'),
validator: (val) =>
val.isEmpty ? 'Email can\'t be empty.' : null,
onSaved: (val) => _email = val,
),
new TextFormField(
key: new Key('password'),
decoration: new InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (val) =>
val.isEmpty ? 'Password can\'t be empty.' : null,
onSaved: (val) => _password = val,
),
new RaisedButton(
key: new Key('login'),
child: new Text('Login', style: new TextStyle(fontSize: 20.0)),
onPressed: validateAndSubmit
),
new Container(
height: 80.0,
padding: const EdgeInsets.all(32.0),
child: buildHintText())
],
)
)
)
);
}
Widget buildHintText() {
return new Text(
_authHint,
key: new Key('hint'),
style: new TextStyle(fontSize: 18.0, color: Colors.grey),
textAlign: TextAlign.center);
}
}
Let's break this down:
- In the
build()
method, we create aForm
to hold twoTextFormFields
(for email and password) and aRaisedButton
(our login button). - The email and password fields have a simple validator that returns
false
if the text input is empty. - When the Login button is tapped, the
validateAndSubmit()
method is called. - This calls
validateAndSave()
, which validates the fields inside the form, and saves the_email
and_password
if they are non-empty. - If
validateAndSave()
returnstrue
, we callFirebase.instance.signInWithEmailAndPassword()
to sign in the user. - Once this call returns, we set the
_authHint
string. This is wrapped in asetState()
method to schedule a rebuild of theLoginPage
widget and update the UI. - The
buildHintText()
method uses the_authHint
string to inform the user of the authentication result.
Here is a preview of our Flutter app:
So, what do we want to test here?
Acceptance criteria
We want to test the following scenarios:
Given the email or password is empty
When the user taps on the login button
Then we don't attempt to sign in with Firebase
And the confirmation message is empty
Given the email and password are both non-empty
And they do match an account on Firebase
When the user taps on the login button
Then we attempt to sign in with Firebase
And we show a success confirmation message
Given the email and password are both non-empty
And they do not match an account on Firebase
When the user taps on the login button
Then we attempt to sign in with Firebase
And we show a failure confirmation message
Writing the first test
Let's write the a widget test for the first scenario:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:login/login_page.dart';
void main() {
testWidgets('empty email and password doesn\'t call sign in', (WidgetTester tester) async {
// create a LoginPage
LoginPage loginPage = new LoginPage(title: 'test');
// add it to the widget tester
await tester.pumpWidget(loginPage);
// tap on the login button
Finder loginButton = find.byKey(new Key('login'));
await tester.tap(loginButton);
// 'pump' the tester again. This causes the widget to rebuild
await tester.pump();
// check that the hint text is empty
Finder hintText = find.byKey(new Key('hint'));
expect(hintText.toString().contains(''), true);
});
}
Note: When running widget tests, the build()
method is not called automatically if setState()
is executed. We need to explicitly call tester.pump()
to trigger a new call to build()
.
If we type flutter test
on the terminal to run our test, we get the following:
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building Scaffold(dirty, state: ScaffoldState#56c5e):
No MediaQuery widget found.
Scaffold widgets require a MediaQuery widget ancestor.
The specific widget that could not find a MediaQuery ancestor was:
Scaffold
The ownership chain for the affected widget is:
Scaffold ← LoginPage ← [root]
Typically, the MediaQuery widget is introduced by the MaterialApp or WidgetsApp widget at the top of your application widget tree.
As explained in this StackOverflow answer, we need to wrap our widget with a MediaQuery
and a MaterialApp
:
Widget buildTestableWidget(Widget widget) {
// https://docs.flutter.io/flutter/widgets/MediaQuery-class.html
return new MediaQuery(
data: new MediaQueryData(),
child: new MaterialApp(home: widget)
);
}
// create a LoginPage
LoginPage loginPage = new LoginPage(title: 'test');
// add it to the widget tester
await tester.pumpWidget(buildTestableWidget(loginPage));
If we run this again, the test passes! ✅
Sign in tests
Let's write a test for our second scenario:
Given the email and password are both non-empty
And they do match an account on Firebase
When the user taps on the login button
Then we attempt to sign in with Firebase
And we show a success confirmation message
testWidgets('non-empty email and password, valid account, calls sign in, succeeds', (WidgetTester tester) async {
LoginPage loginPage = new LoginPage(title: 'test');
await tester.pumpWidget(buildTestableWidget(loginPage));
Finder emailField = find.byKey(new Key('email'));
await tester.enterText(emailField, 'email');
Finder passwordField = find.byKey(new Key('password'));
await tester.enterText(passwordField, 'password');
Finder loginButton = find.byKey(new Key('login'));
await tester.tap(loginButton);
await tester.pump();
Finder hintText = find.byKey(new Key('hint'));
expect(hintText.toString().contains('Signed In'), true);
});
If we run this test, our expectation on hintTest
fails. ❌
Some debugging with breakpoints reveals that this test returns before we reach the setState()
line after signInWithEmailAndPassword()
:
FirebaseUser user = await FirebaseAuth.instance
.signInWithEmailAndPassword(email: _email, password: _password);
setState(() {
_authHint = 'Success\n\nUser id: ${user.uid}';
});
In other words...
Because signInWithEmailAndPassword()
is an asynchronous call, and we need to await for it to return, the next line is not executed within the test.
When running widget tests this is undesirable:
- All code running inside our tests should be synchronous.
- Widget/unit tests should run in isolation and not talk to the network.
Could we replace our call to Firebase with something we have control over, like a test mock?
Yes we can. 😎
Step 1. Let's move our Firebase call inside an Auth
class:
abstract class BaseAuth {
Future<String> signIn(String email, String password);
}
class Auth implements BaseAuth {
Future<String> signIn(String email, String password) async {
FirebaseUser user = await FirebaseAuth.instance.signInWithEmailAndPassword(email: email, password: password);
return user.uid;
}
}
Note that we return a user id as String
. This is so we don't leak Firebase types to the code using BaseAuth
. Because the sign in is asynchronous, we wrap the result inside a Future
.
Step 2. With this change, we can inject our Auth
object when the LoginPage
is created:
class LoginPage extends StatefulWidget {
LoginPage({Key key, this.title, this.auth}) : super(key: key);
final String title;
final BaseAuth auth;
@override
_LoginPageState createState() => new _LoginPageState();
}
// then, in _LoginPageState.validateAndSubmit():
String userId = await widget.auth.signIn(_email, _password);
Note how our LoginPage
holds a reference to the BaseAuth
abstract class, rather than the concrete Auth
version.
Step 3. We can create an AuthMock
class for our tests:
class AuthMock implements Auth {
AuthMock({this.userId});
String userId;
bool didRequestSignIn = false;
Future<String> signIn(String email, String password) async {
didRequestSignIn = true;
if (userId != null) {
return Future.value(userId);
} else {
throw StateError('No user');
}
}
}
Notes
- The
AuthMock.signIn()
method returns immediately when called. - We can instrument our mock to return either a user id, or throw an error. This can be used to simulate a successful or failed response from Firebase.
With this setup we can write the last two tests, making sure to inject our mock when creating a LoginPage
instance:
testWidgets('non-empty email and password, valid account, calls sign in, succeeds', (WidgetTester tester) async {
// mock with a user id - simulates success
AuthMock mock = new AuthMock(userId: 'uid');
LoginPage loginPage = new LoginPage(title: 'test', auth: mock);
await tester.pumpWidget(buildTestableWidget(loginPage));
Finder emailField = find.byKey(new Key('email'));
await tester.enterText(emailField, 'email');
Finder passwordField = find.byKey(new Key('password'));
await tester.enterText(passwordField, 'password');
Finder loginButton = find.byKey(new Key('login'));
await tester.tap(loginButton);
await tester.pump();
Finder hintText = find.byKey(new Key('hint'));
expect(hintText.toString().contains('Signed In'), true);
expect(mock.didRequestSignIn, true);
});
testWidgets('non-empty email and password, invalid account, calls sign in, fails', (WidgetTester tester) async {
// mock without user id - throws an error and simulates failure
AuthMock mock = new AuthMock(userId: null);
LoginPage loginPage = new LoginPage(title: 'test', auth: mock);
await tester.pumpWidget(buildTestableWidget(loginPage));
Finder emailField = find.byKey(new Key('email'));
await tester.enterText(emailField, 'email');
Finder passwordField = find.byKey(new Key('password'));
await tester.enterText(passwordField, 'password');
Finder loginButton = find.byKey(new Key('login'));
await tester.tap(loginButton);
await tester.pump();
Finder hintText = find.byKey(new Key('hint'));
expect(hintText.toString().contains('Sign In Error'), true);
expect(mock.didRequestSignIn, true);
});
If we run our tests again, we now get a green light! ✅ Bingo! 🚀
Note: we can choose to write our expectations either on our mock object, or on the hintText
widget. When writing widget tests, we should always be able to observe changes at the UI level.
Conclusion
When writing unit or widget tests, identify all the dependencies of your system under test (some of them may run code asynchronously). Then:
- Inject dependencies as abstract classes into your widgets.
- Instrument your tests with mocks and ensure they return immediately.
- Write your expectations against the widgets or your mocks.
- [Flutter specific] call
tester.pump()
to cause a rebuild on your widget under test.
Full source code is available on this GitHub repo. This includes a full user registration form in addition to the login form.
Happy coding!