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:
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:
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
:
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
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()
:
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:
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:
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:
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:
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.
Youβre welcome!
What is your experience with testing in Flutter? Let me know in the comments. π
References
Read Next: How fast is Flutter? I built a stopwatch app to find out
LEARN FLUTTER TODAY
Sign up for updates and get my free Flutter Layout Cheat Sheet.