• Docs
  • Showcase
  • Community
  • DMCA
Sunday, February 28, 2021
Flutter Website
No Result
View All Result
  • Login
  • Register
Flutter Website
  • Home
  • Categories
    • Flutter App
    • Flutter Examples
    • Flutter Github
    • Flutter News
    • Flutter PDF
    • Flutter Tips & Trick
    • Flutter Tutorial
    • Flutter UI
    • Flutter Video
    • Flutter VS
    • Flutter Wiki
    • Flutter With
  • Flutter
    • Flutter for Mobile
    • Flutter for Web
      • Widget Sample
    • Flutter for Desktop
    • Tools
      • Codemagic
      • Flutter Studio
      • Supernova
  • Showcase
  • Community
  • Advertisement
  • Hire Us
No Result
View All Result
Flutter Website
Home Flutter Tips & Trick

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

Andrea Bizzotto

flutter by flutter
Reading Time: 14min read
426
396
SHARES
566
VIEWS
Share on FacebookShare on TwitterShare on LinkedinShare to Whatsapp

Table of Contents

  • Use case: Login form with Firebase authentication
    • Acceptance criteria
    • Writing the first test
    • Sign in tests
    • Conclusion

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[email protected]

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:

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

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:

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

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!

Tags: Andrea Bizzotto

Related Posts

Flutter Tutorial

Level up your Flutter apps with autofill

608
In-app purchases with flutter: a comprehensive step-by-step tutorial
Flutter Tutorial

In-App Purchases with Flutter: A Comprehensive Step-by-Step tutorial

656
Adding a splash screen to flutter web
Flutter Tutorial

Adding a Splash Screen to Flutter Web

610
How to animate items in list using animatedlist in flutter
Flutter Tips & Trick

How To Animate Items In List Using AnimatedList In Flutter

662
Firebase push notifications: notify your users
Flutter Tutorial

Firebase Push Notifications: Notify your users

616
Flutter and desktop apps
Flutter App

Flutter and Desktop Apps

585
  • Flutter & dart – the complete guide [2020 edition]

    Flutter & Dart – The Complete Guide [2020 Edition]

    2271 shares
    Share 908 Tweet 568
  • The Complete 2020 Flutter Development Bootcamp with Dart

    1989 shares
    Share 796 Tweet 497
  • Flutter & Firebase: Build a Complete App for iOS & Android

    1422 shares
    Share 569 Tweet 356
  • Flutter Bloc & Cubit Tutorial

    1295 shares
    Share 518 Tweet 324
  • 40 Beautiful Flutter UI Themes For Developers

    1096 shares
    Share 438 Tweet 274

Made by Google

Flutter is Google’s portable UI toolkit for building beautiful, natively-compiled applications for mobile, web, and desktop from a single codebase.

Follow us

Recent Post

  • Adaptive Layouts part 2 (The Boring Flutter Development Show, Ep. 46)
  • VisualEyes marketing site – UI ideas for flutter
  • Final vs Const – Programming #Shorts
  • Intro to Git – Learning Git and GitHub – Integration and Alternatives
  • Unsplash iOS app – UI ideas for flutter

Popular Post

Pageview (flutter widget of the week)

PageView (Flutter Widget of the Week)

531
A pie chart widget with cool animation

A Pie Chart Widget with cool animation

526

Review Post

Flutter themeswitcher template in flutter

Flutter ThemeSwitcher Template in Flutter

Congratulations, Nice Work, GLWS $7
Rosen – flutter ecommerce ui

Rosen - Flutter Ecommerce UI

Nice Product I am gonna love it. $18
  • [email protected]
  • Flutter Terms
  • Flutter Packages
  • Dart

Copyright © 2021 Flutter Website - by Flutter Team.

No Result
View All Result
  • Home
  • Categories
    • Flutter App
    • Flutter Examples
    • Flutter Github
    • Flutter News
    • Flutter PDF
    • Flutter Tips & Trick
    • Flutter Tutorial
    • Flutter UI
    • Flutter Video
    • Flutter VS
    • Flutter Wiki
    • Flutter With
  • Flutter
    • Flutter for Mobile
    • Flutter for Web
      • Widget Sample
    • Flutter for Desktop
    • Tools
      • Codemagic
      • Flutter Studio
      • Supernova
  • Showcase
  • Community
  • Advertisement
  • Hire Us
  • Login
  • Sign Up

Copyright © 2021 Flutter Website - by Flutter Team.

Welcome Back!

Sign In with Facebook
Sign In with Google
OR

Login to your account below

Forgotten Password? Sign Up

Create New Account!

Sign Up with Facebook
Sign Up with Google
OR

Fill the forms below to register

All fields are required. Log In

Retrieve your password

Please enter your username or email address to reset your password.

Log In
This website uses cookies. By continuing to use this website you are giving consent to cookies being used. Visit our Privacy and Cookie Policy.
Go to mobile version