Take your Flutter tests to the next level with abstract classes and dependency injection

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 a Form to hold two TextFormFields (for email and password) and a RaisedButton (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() returns true, we call Firebase.instance.signInWithEmailAndPassword() to sign in the user.
  • Once this call returns, we set the _authHint string. This is wrapped in a setState() method to schedule a rebuild of the LoginPage 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!

References

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.