Passwordless sign-in with Firebase in Flutter — Part 3 — Writing Unit Tests

Hashem Abounajmi
26 min readJun 25, 2023

You came along a long path, Now we have our passwordless sign-in feature ready, we could demonstrate that by manual testing. Our beloved app will evolve as we are adding more features to it and a question remians:

how we can guarantee the app works as it is expected?

would you like to verify by manual testing? every week we are releasing our app on the AppStore, manual testing is not possible as we are human and we get bored of repetitive task and its time consuming. you may say the QA team will find the bug! but remember our job as a software developer is to reduce human cost!

Rule number 8: By default your software is not working unless you prove against it.

It doesn’t matter how well you have written your software, the assumption is its not working unless you prove it with tests result.

Unit Testing PasswordlessAuthenticator

Concept of testing is simple:

for a given input when an action happened, I should receive expected output

All tests we are going to write follow above principle. A Given part that defines the input, a When part that defines a single action happened in the system and Then part examines the output.

Consider this chapter as an exercise for writing tests. We are going to write dozens of tests, it helps your brain get trained and the next time you want to write on your own, it becomes your nature.

Practice makes perfection.

As we are writing the tests you learn more.

PasswordlessAuthenticator has three public apis: sendSignInLinkToEmail, signInWithEmailLink and checkEmailLink. we need to write tests for all three to be ensure authenticator works as expected.

Think about what is the first thing we want to test? 🤔

Always start with failure path

Clone the example project from the Github repository, and then checkout unit-tests tag to follow along this article:

git checkout unit-tests

1. Test sendSignInLinkToEmail

1.1. sendSignInLinkToEmail fails when FirebaseAuth throws error

We write the first test description as follow:

By calling sendSignInLinkToEmail, when firebase throws error, api should delivers failure to the user.

Lets implement above test:

Create a file named passwordless_authenticator_test.dart in test/auth folder and add below code:

import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_dynamic_links/firebase_dynamic_links.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:mocktail/mocktail.dart';
import 'package:passwordless_signin/auth/email_secure_store.dart';
import 'package:passwordless_signin/auth/models/failure.dart';
import 'package:passwordless_signin/auth/models/sign_in_link_settings.dart';
import 'package:passwordless_signin/auth/models/user.dart' as app_user_model;
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';

import 'mocks/firebase_auth_spy.dart';

// 1
class MockFirebaseDynamicLinks extends Mock implements FirebaseDynamicLinks {}
class MockEmailSecureStorage extends Mock implements EmailSecureStore {}

void main() {
// 4
late FirebaseAuthSpy firebaseAuth;

late FirebaseDynamicLinks firebaseDynamicLinks;
late EmailSecureStore emailSecureStore;
late ActionCodeSettings actionCodeSettings;
late PasswordlessAuthenticator sut;

const email = 'myid@email.com';
final signinLinkSettings = SignInLinkSettings(
url: 'https://a-url.com',
androidPackageName: 'com.name.android',
iOSBundleId: 'com.name.ios',
dynamicLinkDomain: 'subdomian.page.link',
);

setUp(() {

firebaseAuth = FirebaseAuthSpy();
firebaseDynamicLinks = MockFirebaseDynamicLinks();
emailSecureStore = MockEmailSecureStorage();
actionCodeSettings = ActionCodeSettings(
url: signinLinkSettings.url,
handleCodeInApp: true,
androidInstallApp: true,
androidPackageName: signinLinkSettings.androidPackageName,
iOSBundleId: signinLinkSettings.iOSBundleId,
dynamicLinkDomain: signinLinkSettings.dynamicLinkDomain,
);

// 2
sut = PasswordlessAuthenticator(
firebaseAuth,
firebaseDynamicLinks,
emailSecureStore,
signinLinkSettings,
);
});

// 3
group('sendSignInLinkToEmail', () {
test(
'delivers error on FirebaseAuth error',
() async {
// given
when(
() => emailSecureStore.setEmail(email),
).thenAnswer((_) async {});

// when
sut.sendSignInLinkToEmail(email).then((result) {
// Then
expect(result, left(const Failure.unexpectedError()));
});

firebaseAuth
.completeSendLinkWithFailure(FirebaseAuthException(code: '0'));
},
);
}
}

1. In order to write the test we need an instance of PasswordlessAuthenticator and we call it sut or system under test and for instantiating that we need to create and pass all the dependencies:

sut = PasswordlessAuthenticator(
firebaseAuth,
firebaseDynamicLinks,
emailSecureStore,
signinLinkSettings,
);

But the problem is real FirebaseAuth communicate with real Firebase project and needs internet connection and we can’t simulate all scenarios with real instance. same is true for FirebaseDynamicLinks and EmailSecureStore . So we need a mocked version that does immediately whatever we ask.
You may ask then how we trust tests result when we are using fake objects? The real thing is we are testing PasswordlessAuthenticator to verify its public functions work the way we want, we don’t care about reality of its dependencies. The object consumes responses of its dependencies, how response is generated doesn’t matter. Its fine to use a mocked of version of dependencies to facilitate our testing otherwise testing the sut would not be possible. This is called testing in isolation.

To create mocks, we use a library named mocktail. I suggest read its read-me to have proper understanding of its usage.

Add mocktail as a dev dependency:

dev_dependencies:
...
mocktail: ^0.3.0

2. We used setup method to instantiate sut which is PasswordlessAuthenticator and we run tests against that.

3. We group all related tests for sendSignInLinkToEmail . we read the first test as follow:

Test description

If we look inside the sendSignInLinkToEmail method, we see before calling FirebaseAuth.sendSignInLinkToEmail it calls setEmail on EmailSecureStore.

Now for the Given part we need to mock above highlighted methods. as they are dependencies methods.

Given:

when(
() => emailSecureStore.setEmail(email),
).thenAnswer((_) async {});

Translating to: when setEmail is called return an empty response (the method expected return type). setEmail returns a dump premade response whenever is called. Don’t confuse the when method used above as it setups the given part.

When:

By calling the sut.sendSignInLinkToEmail

Then:

We expect a failure response:

expect(result, left(const Failure.unexpectedError()));

And finally we complete the FirebaseAuth sendLink with a failure response.

firebaseAuth.completeSendLinkWithFailure(FirebaseAuthException(code: '0'));

Diagram below simplifies understanding the test process. We have followed the steps shown below to validate the output.

Testing Diagram

4. what's FirebaseAuthSpy?

Lets talk about this special mock class. Create a file named firebase_auth_spy.dart in test/auth/mock folder:

import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:mocktail/mocktail.dart';
import 'package:rxdart/subjects.dart';

class FakeUserCredentials extends Fake implements UserCredential {
@override
User? get user => FakeUser();
}

class FakeUser extends Fake implements User {
@override
String get uid => '1';
}

class FirebaseAuthSpy extends Mock implements FirebaseAuth {
List<(String, ActionCodeSettings)> sendSignInLinkMessages = [];
List<(String, String)> signInMessages = [];
Completer<void> sendSigninLinkCompleter = Completer<void>();
Completer<UserCredential> signInWithEmailLinkCompleter =
Completer<UserCredential>();
BehaviorSubject<User?> userController = BehaviorSubject();

@override
Future<void> sendSignInLinkToEmail({
required String email,
required ActionCodeSettings actionCodeSettings,
}) async {
sendSignInLinkMessages.add((email, actionCodeSettings));
return sendSigninLinkCompleter.future;
}

void completeSendLinkWithSuccess([int index = 0]) {
sendSigninLinkCompleter.complete();
}

void completeSendLinkWithFailure(Exception error, [int index = 0]) {
sendSigninLinkCompleter.completeError(error);
}

@override
Future<UserCredential> signInWithEmailLink({
required String email,
required String emailLink,
}) async {
signInMessages.add((email, emailLink));
return signInWithEmailLinkCompleter.future;
}

void completeSigninWithLinkWithSuccess([int index = 0]) {
final UserCredential userCredential = FakeUserCredentials();
userController.add(userCredential.user);
signInWithEmailLinkCompleter.complete(userCredential);
}

void completeSigninWithLinkWithFailure(Exception error, [int index = 0]) {
signInWithEmailLinkCompleter.completeError(error);
}

@override
Stream<User?> authStateChanges() => userController.stream;
}

This file named Spy as it records interaction on FirebaseAuth. look at sendSignInLinkMessages and signInMessages , they store parameters passed to each method so we can use them to verify method is called with right parameters. Also it extends Mock as we don’t want to be forced to implement all of its methods but we want to be able to mock those methods later.

Note: You can’t mock implemented methods in the Mock Object.

Means we can’t mock sendSignInLinkToEmai and signInWithEmailLink as they are implemented in the FirebaseAuthSpy. But we mocked them based on our special requirments. if you look at the completeSigninWithLinkWithSuccess, we pass a user to the userController, so authStateChanges will emit a user in successful sign-in. I couldn’t achieve this with Mocktail, hence I relied on my custom mock implementation of FirebaseAuth.

Now just run your test by running flutter test in terminal:

Our tests structure is setup. we just need to write other tests and verify the outputs. so other tests would be fairly straight forward.

1.2. delivers error on EmailSecureStore error

Now lets try to implement second test:

when EmailSecureStore throws error, sendSignInLinkToEmail should return failure.

Testing Diagram

I suggest to take a moment and try to write the test on your own. it helps you consolidate your learning. even if you can’t write to full test, just think about given/when/then parts.

 test(
'delivers error on EmailSecureStore error',
() async {
// Given
when(
() => emailSecureStore.setEmail(email),
).thenThrow(() => Exception());
// When
sut.sendSignInLinkToEmail(email).then((result) {
// Then
expect(result, left(const Failure.unexpectedError()));
});
firebaseAuth.completeSendLinkWithSuccess();
},
);

You may ask why we have not written the test as below 🤔:

// When
await sut.sendSignInLinkToEmail(email);

firebaseAuth.completeSendLinkWithSuccess();

// Then
expect(result, left(const Failure.unexpectedError()));

If we write like above, and use async/await, sut.sendSignInLinkToEmail never completes and the test stuck forever, because in the next line we complete the method with success response, and because of the await, test can’t reach the next line.

1.3. delivers success on successful sendSignInLinkToEmail

sendSignInLinkToEmail should return success when FirebaseAuth completes with success.

I had draw a simple diagram to make it easier to understand how we are testing.

Testing Diagram
test(
'delivers success on successful sendSignInLinkToEmail',
() async {
// Given
when(
() => emailSecureStore.setEmail(email),
).thenAnswer((_) async {});
// when
sut.sendSignInLinkToEmail(email).then((result) {
// Then
expect(result, right(unit));
});
firebaseAuth.completeSendLinkWithSuccess();
},
);

You see based on the diagram its super easy to write the tests.

1.4. Testing parameters

One question remains: how we sure we are passing right parameters to the FirebaseAuth corresponding method? maybe those parameters are accidentally hard coded! You remeber FirebaseAuthSpy was recording the passed parameters? here is the place FirebaseAuthSpy comes to rescue:

sut calls sendEmailToLinkToEmail on FirebaseAuth with right parameters

test(
'sendSignInLinkToEmail calls FirebaseAuth sendSignInLinkToEmail with right parameters',
() async {
when(
() => emailSecureStore.setEmail(email),
).thenAnswer((_) async {});

sut.sendSignInLinkToEmail(email).then(
(value) {
// 1
expect(
firebaseAuth.sendSignInLinkMessages.length,
1,
);

final receivedEmail = firebaseAuth.sendSignInLinkMessages.first.$1;
final receivedSettings =
firebaseAuth.sendSignInLinkMessages.first.$2;
expect(
receivedEmail,
email,
);
// 2
expect(
receivedSettings.url,
actionCodeSettings.url,
);
expect(
receivedSettings.androidPackageName,
actionCodeSettings.androidPackageName,
);
expect(
receivedSettings.iOSBundleId,
actionCodeSettings.iOSBundleId,
);
expect(
receivedSettings.dynamicLinkDomain,
actionCodeSettings.dynamicLinkDomain,
);
expect(
receivedSettings.dynamicLinkDomain,
actionCodeSettings.dynamicLinkDomain,
);
expect(receivedSettings.handleCodeInApp, isTrue);
expect(receivedSettings.androidInstallApp, isTrue);
},
);

firebaseAuth.completeSendLinkWithSuccess();
},
);

1. Testing sendSignInLink has called only once, when requested sendSignInLinkToEmail by the sut.

2. Now we check passed settings are exactly passed to the FirebaseAuth by comparing the properties. ActionCodeSettings in FirebaseAuth doesn’t override equality operator, so we can’t compare two objects in one go.

Note!

By mocking setEmail, we already verifying the right email is passed to the setEmail . You see we passed email to the setEmail. So if other email as the intended one gets passed to the setEmail, the mocking will not work. Try and see.

View Code Coverage

Code coverage helps us to view the lines of code we have covered during testing and the lines we have missed, so we can quickly review the lines we should write tests for them. Code coverage also gives you a percentage of total coverage. we aim to maximize this percentage but %100 not needed as long as you are confident you have covered important parts with tests, the coverage is enough.

  1. Install homebrew, a MacOS dependency manager by entering below code in terminal:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

2. Then install lcov package view homebrew:

brew install lcov 

lcov helps to view code coverage info generated by flutter in an html. lets try below in terminal:

flutter test --coverage
genhtml coverage/lcov.info -o coverage/html

You will see a folder named coverage is created with html folder and a file named lcov.info, which contains test coverage inormation.

Add coverage folder to your .gitignore file. We don’t need these generated files to be included in the git repository.

coverage/

Now openindex.html file in html folder and browse it:

Code Coverage

You see we have only covered 22.1% of PasswordlessAuthentocator.

Now let’s continue our testing.

2. Test signInWithEmailLink

The process is same as the previous one. We first try to find out which methods we should mock and then start by testing the failure paths:

Methods needs to be mocked

As we look at above code what is the first line that can cause failure? If user is already signed-in we return error.

2.1. delivers EmailLinkFailure.userAlreadySignedIn when current user is not null

We need only mock FirebaseAuth for the current user to return a user. The method should immediately return userAlreadySignedIn error.

📌 For each test, please take a moment and think how you should write before looking at the answer.

test(
"delivers EmailLinkFailure.userAlreadySignedIn when current user is not null",
() async {
// Given
when(() => firebaseAuth.currentUser).thenReturn(FakeUser());

// When
final result = await sut.signInWithEmailLink(emailLink);

// Then
expect(
result,
left(const EmailLinkFailure.userAlreadySignedIn()),
);
},
);

How do you think? as you write more tests, it becomes your routine and becomes much simpler.

2.2. delivers EmailLinkFailure.emailNotSet when user email is empty

As we read through the method implementation, the next error is when email is not set.

test(
"delivers EmailLinkFailure.emailNotSet when user email is empty",
() async {
// Given
when(
() => emailSecureStore.getEmail(),
).thenAnswer((_) async => null);

// When
final result = await sut.signInWithEmailLink(emailLink);

// Then
expect(
result,
left(const EmailLinkFailure.emailNotSet()),
);
},
);

2.3. Delivers EmailLinkFailure.isNotSignInWithEmailLink when the link is not a sign-in link.

Translate the diagram to the code:

test(
'delivers EmailLinkFailure.isNotSignInWithEmailLink when the link is not a sign-in link',
() async {
// Given
when(
() => emailSecureStore.getEmail(),
).thenAnswer((_) async => email);
when(() => firebaseAuth.isSignInWithEmailLink(emailLink.toString()))
.thenReturn(false);

// When
final result = await sut.signInWithEmailLink(emailLink);

// Then
expect(
result,
left(const EmailLinkFailure.isNotSignInWithEmailLink()),
);
},
);

2.4. Delivers EmailLinkFailure.signInFailed when the user email can’t be retrieved.

test(
"delivers EmailLinkFailure.signInFailed when the user email can't be retrieved",
() async {
// Given
final exception = Exception("email can't be retrieved");
when(
() => emailSecureStore.getEmail(),
).thenThrow(exception);

// When
final result = await sut.signInWithEmailLink(emailLink);

// Then
expect(
result,
left(EmailLinkFailure.signInFailed(exception.toString())),
);
},
);

You may wondering why all of our tests are passing, none of them are failing? smile 😀, it means our code until now is working as expected. If you change any of the tested lines in signInWithEmailLink, the tests fails and you can easily spot the breaking code.

2.5. delivers EmailLinkFailure.signInFailed on FirebaseAuth error

We need to mock all inner methods until we reach the signInWithEmailLink method.

test(
'delivers EmailLinkFailure.signInFailed on FirebaseAuth error',
() async {
// Given
final exception =
FirebaseAuthException(code: '0', message: 'failed to signin');
when(
() => emailSecureStore.getEmail(),
).thenAnswer((_) async => email);
when(() => firebaseAuth.isSignInWithEmailLink(emailLink.toString()))
.thenReturn(true);

// When
sut.signInWithEmailLink(emailLink).then(
(result) =>
// Then
expect(
result,
left(EmailLinkFailure.signInFailed(exception.message)),
),
);
firebaseAuth.completeSigninWithLinkWithFailure(exception);
},
);

2.6. Deliver success on Successful sign-in

test(
'delivers success on successfull sign-in',
() async {

// Given
when(
() => emailSecureStore.getEmail(),
).thenAnswer((_) async => email);
when(() => firebaseAuth.isSignInWithEmailLink(emailLink.toString()))
.thenReturn(true);

// When
sut.signInWithEmailLink(emailLink).then(
(result) {
// Then
expect(
result,
right(unit),
);
},
);

// Then
expect(sut.authStateChanges(), emits(Some(app_user_model.User('1'))));

firebaseAuth.completeSigninWithLinkWithSuccess();
},
);

📌 On successful sign-in we need to verify we have a logged in user. you see how we are testing an stream in dart. authStateChanges is an stream and we need to ensure it emits a user after a successful sign-in, otherwise returning an empty success response from signInWithEmailLink doesn’t have any value.

Lets review the code coverage until now:

Code coverage

Wow interesting from 22% to 60% 🤩 its amazing. as you see Models are 100% covered without directly testing them.

Before continuing, please take a rest and cool down your mind, we have other interesting tests.

3. checkEmailLink

We have covered two of three PasswordlessAuthenticator methods. Now lets write tests for the last one: checkEmailLink

I hope you still remember our number #1 rule: we first write tests for failures paths and then for success path. If you look at the checkEmailLink, there are two different way to get initial link, one through stream which is named onLink and the second is getInitialLink. we start with getInitialLink .

checkEmailLink

If getInitialLink fails, it calls onSigninFailure . so our first test would be:

3.1. checkEmailLink calls onSigninFailure when getInitialLink fails.

 group('checkEmailLink', () {
final signinLink = Uri.https('sigin-link.com');

group('getInitialLink', () {
test('calls onSigninFailure when getInitialLink fails', () async {

// Given
final exception = Exception('an exception message');
sut.onSigninFailure = (value) {
// Then
expect(value, EmailLinkFailure.linkError(exception.toString()));
};
when(() => firebaseDynamicLinks.getInitialLink()).thenThrow(exception);
when(() => firebaseDynamicLinks.onLink).thenAnswer(
(_) => StreamController<PendingDynamicLinkData>().stream,
);

// When
sut.checkEmailLink();
});
}
}

As you see above Then part is before When. the reason is onSigninFailure is a closure and it should have a value before calling checkEmailLink. Note on onLink to see how we mock streams.

The next test is getInitialLink returns a link, but signInWithEmailLink fails.

3.2. checkEmailLink calls onSigninFailure when signInWithEmailLink fails

Looking at above diagram and understanding it make writing tests much easier.

 test('calls onSigninFailure when signInWithEmailLink fails', () async {
// Given
when(() => firebaseDynamicLinks.getInitialLink())
.thenAnswer((_) async => PendingDynamicLinkData(link: signinLink));
when(() => firebaseDynamicLinks.onLink).thenAnswer(
(_) => StreamController<PendingDynamicLinkData>().stream,
);
when(
() => emailSecureStore.getEmail(),
).thenAnswer((_) async => null);

sut.onSigninFailure = (value) {
// Then
expect(value, const EmailLinkFailure.emailNotSet());
};

// When
final _ = await sut.checkEmailLink();
});

We want signInWithEmailLink returns error, how we do it? we mocked getEmail to return empty email, which causes signInWithEmailLink returns failure.

We are just following an structured tests and we are not adding any new code. Tests are telling us we are on the right track.

3.3. emits loading while signInWithEmailLink is in progress

This is an interesting test. we want to test while signInWithEmailLink is in progress, loading stream emits true, and when signInWithEmailLink finished, it emits false.

test('emits loading while signInWithEmailLink is in progress', () async {
// Given
final signinLinkStreamController =
StreamController<PendingDynamicLinkData>();

when(() => firebaseDynamicLinks.getInitialLink()).thenAnswer(
(invocation) async => PendingDynamicLinkData(link: signinLink),
);
when(() => firebaseDynamicLinks.onLink).thenAnswer(
(_) => signinLinkStreamController.stream,
);
when(
() => emailSecureStore.getEmail(),
).thenAnswer((_) async => null);

// When
sut.checkEmailLink();

// Then
expect(sut.isLoading, emitsInOrder([true, false]));
});

Now its time to test onLink and listening to onLink stream

3.4. onLink calls onSigninFailure when onLink stream emits failure

Create a new group test named onLink and below test in it.

group('onLink', () {
test('calls onSigninFailure when onLink stream emits failure', () async {
// Given
final exception = Exception('an exception message');
final signinLinkStreamController =
StreamController<PendingDynamicLinkData>();

sut.onSigninFailure = (value) {
// Then
expect(value, EmailLinkFailure.linkError(exception.toString()));
};
when(() => firebaseDynamicLinks.getInitialLink())
.thenAnswer((invocation) async => null);
when(() => firebaseDynamicLinks.onLink).thenAnswer(
(_) => signinLinkStreamController.stream,
);

// When
sut.checkEmailLink();
signinLinkStreamController.addError(exception);
});
}

Streams are like a pipeline and we can send value or error to them via .add or .addError.

Next test is when called signInWithEmailLink in onLink listen closure gets failed.

3.5 onLink calls onSigninFailure when signInWithEmailLink fails

test('calls onSigninFailure when signInWithEmailLink fails', () async {

// Given
final signinLinkStreamController =
StreamController<PendingDynamicLinkData>();
when(() => firebaseDynamicLinks.getInitialLink())
.thenAnswer((invocation) async => null);
when(() => firebaseDynamicLinks.onLink).thenAnswer(
(_) => signinLinkStreamController.stream,
);
when(
() => emailSecureStore.getEmail(),
).thenAnswer((_) async => null);

sut.onSigninFailure = (value) {
// Then
expect(value, const EmailLinkFailure.emailNotSet());
};

// When
sut.checkEmailLink();
signinLinkStreamController
.add(PendingDynamicLinkData(link: signinLink));
});

Next test is testing isLoading stream emits loading in the right order:

3.6. onLink emits loading while signInWithEmailLink is in progress

 test('emits loading while signInWithEmailLink is in progress', () async {

// Given
final signinLinkStreamController =
StreamController<PendingDynamicLinkData>();
when(() => firebaseDynamicLinks.getInitialLink())
.thenAnswer((_) async => null);
when(() => firebaseDynamicLinks.onLink).thenAnswer(
(_) => signinLinkStreamController.stream,
);
when(
() => emailSecureStore.getEmail(),
).thenAnswer((_) async => null);

// When
sut.checkEmailLink();
signinLinkStreamController
.add(PendingDynamicLinkData(link: signinLink));

// Then
expect(sut.isLoading, emitsInOrder([true, false]));
});

3.7. Signout user

For this test we need to verify authStateChanges emits no user and FirebaseAuth.signOut is called once. checking the dependency method is called ensure us dependency is used.

Open FirebaseAuthSpy and add below method and property:

bool signoutCalled = false;
....
@override
Future<void> signOut() async {
signoutCalled = true;
userController.add(null);
}

And here is the test:

test('Signout user', () async {
await sut.signout();
expect(sut.authStateChanges(), emits(none()));
expect(firebaseAuth.signoutCalled, isTrue);
});

Now lets review the code coverage again:

flutter test --coverage
genhtml coverage/lcov.info -o coverage/html

We could cover 96.4% of PasswordlessAuthenticator, it means it we have covered almost entire file (dispose method is not covered).

Unit Testing EmailSecureStore

Here we have only two methods: setEmail and getEmail. There are no special logic in each method, they are just redirecting calls to FlutterSecureStorage. So we need to verify those methods are called with right values. Here I have followed a different strategy for testing and instead of using real FlutterSecureStorage I have created Fake implementation of that that instead of storing data on keychain, it stores them in memory. I called new object FakeFlutterSecureStorage .

Fake: As the name implies, It behaves like the real one but internally instead of using a complex implementation, it uses a lightweight ones. like using inMemory cache instead of transferring data to a real database which is super slow during tests.

Create a file named email_secure_store_test.dart and below content:

class FakeFlutterSecureStorage extends Fake implements FlutterSecureStorage {
final dictionary = <String, String?>{};

@override
Future<void> write({
required String key,
required String? value,
IOSOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
MacOsOptions? mOptions,
WindowsOptions? wOptions,
}) async {
dictionary[key] = value;
}

@override
Future<String?> read({
required String key,
IOSOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
MacOsOptions? mOptions,
WindowsOptions? wOptions,
}) async {
return dictionary[key];
}
}

void main() {
late EmailSecureStore sut;
final FakeFlutterSecureStorage flutterSecureStorage =
FakeFlutterSecureStorage();
const email = 'myid@email.com';

setUp(() {
sut = EmailSecureStore(flutterSecureStorage);
});

test(
'setEmail, write email to FlutterSecureStore',
() async {
await sut.setEmail(email);
expect(flutterSecureStorage.dictionary.containsValue(email), isTrue);
expect(flutterSecureStorage.dictionary.length, 1);
},
);

test(
'getEmail, read emails from FlutterSecureStore',
() async {
await sut.setEmail(email);
final cachedEmail = await sut.getEmail();
expect(email, cachedEmail);
},
);
}

For setEmail we check the exact email is stored in the dictionary and for getEmail we check after setting the email we can retrieve it.

Testing BLoC — PasswordlessSigninBloc

Unit testing is all about verifying all small steps in a path. We need to get skilled on understanding those small steps that make the path.

For BLoC testing, for each event there should be a test which verifying corresponding state change.

🖊️ As an exercise please read the bloc events and state model and write on a paper, the description for each test that map event to the state.

I can say figuring out the steps you need to test would be a little challenge for you, writing the test is easy.

After the second test, you should write the next BLoC tests then look at the solution.

1. Initial state should be Initial

Based on what we have covered yet, you can easily write this test.

Create a file named passwordless_signin_bloc_test.dart in test/passwordless_signin/bloc and past below content.

import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:mocktail/mocktail.dart';
import 'package:passwordless_signin/auth/models/failure.dart';
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/passwordless_signin/bloc/passwordless_signin_bloc.dart';

class MockPasswordlessAuthenticator extends Mock
implements PasswordlessAuthenticator {}

void main() {
late PasswordlessAuthenticator authenticator;
late MailAppLauncher mailAppLauncher;
const validEmail = 'myid@email.com';

setUp(() {
authenticator = MockPasswordlessAuthenticator();
});

PasswordlessSigninBloc makeSut() =>
PasswordlessSigninBloc(authenticator, mailAppLauncher);
test('Initial state should be Initial', () {
expect(
makeSut().state,
PasswordlessSigninState.initial(),
);
});

}

We just mock immediate first level dependencies, here we just mock PasswordlessAuthenticator . We don’t need to know the inner details of PasswordlessAuthenticator .

Just mock first level dependencies in Unit Test

For writing next tests, we use bloc_test package, as it simplifies testing with BLoCs.

add bloc_test to your dev dependencies:

dev_dependencies:
bloc_test: ^9.1.3

I suggest to read the bloc_test description as it simply explains how to use it. I don’t want to vaguely rephrase them!

Second test:

2. On email change, email should not be validated

Yes, when user is typing the email, we don’t validate the email, unless user taps on the Sign in button.

blocTest<PasswordlessSigninBloc, PasswordlessSigninState>(
'ON invalid email '
'WHEN sign-in link requested '
'THEN email gets validated '
'AND validation message will be emitted',
build: () => makeSut(),
act: (bloc) {
bloc.add(const PasswordlessSigninEvent.emailChanged('invalidEmail'));
bloc.add(const PasswordlessSigninEvent.sendMagicLink());
},
skip: 1,
expect: () => [
PasswordlessSigninState(
emailAddress: some(left('Enter valid email')),
isSubmitting: false,
failureOrSuccessOption: none(),
)
],
);

I wrote the test description in Given/When/Then style, to become easier to read and understand. I suggest you follow this pattern of writing when writing BLoC tests. In the act part, I sent events to the BLoC. I’m testing the BLoC pure, without connection to the UI. I skipped the first emitted state, as when user enters the email, the first state contains the email, without validation and we are not testing that. run the test and verify.

3. On invalid email, when sign-in link requested, a validation error message should be emitted.

blocTest<PasswordlessSigninBloc, PasswordlessSigninState>(
'ON invalid email '
'WHEN sign-in link requested '
'THEN email gets validated '
'AND validation message will be emitted',
build: () => makeSut(),
act: (bloc) {
bloc.add(const PasswordlessSigninEvent.emailChanged('invalidEmail'));
bloc.add(const PasswordlessSigninEvent.sendMagicLink());
},
skip: 1,
expect: () => [
PasswordlessSigninState(
emailAddress: some(left('Enter valid email')),
isSubmitting: false,
failureOrSuccessOption: none(),
)
],
);

4. On valid email, when sign-in link requested and api returns failure, then an error message should be emitted.

  blocTest<PasswordlessSigninBloc, PasswordlessSigninState>(
'ON valid email '
'WHEN sign-in link requested '
'AND api returns failure '
'THEN error message should be emitted',
build: () => makeSut(),
setUp: () {
when(
() => authenticator.sendSignInLinkToEmail(validEmail),
).thenAnswer(
(_) async =>
left(const Failure.unexpectedError('failed to email the link')),
);
},
act: (bloc) {
bloc.add(const PasswordlessSigninEvent.emailChanged(validEmail));
bloc.add(const PasswordlessSigninEvent.sendMagicLink());
},
skip: 1,
expect: () => [
PasswordlessSigninState(
emailAddress: some(right(validEmail)),
isSubmitting: true,
failureOrSuccessOption: none(),
),
PasswordlessSigninState(
emailAddress: some(right(validEmail)),
isSubmitting: false,
failureOrSuccessOption: some(
left('failed to email the link'),
),
)
],
);

5. On valid email, when sign-in link requested and api returns success, then success should be emitted

blocTest<PasswordlessSigninBloc, PasswordlessSigninState>(
'ON valid email '
'WHEN sign-in link requested '
'AND api returns success '
'THEN success should be emitted',
build: () => makeSut(),
setUp: () {
when(
() => authenticator.sendSignInLinkToEmail(validEmail),
).thenAnswer(
(_) async => right(unit),
);
},
act: (bloc) {
bloc.add(const PasswordlessSigninEvent.emailChanged('myid@email.com'));
bloc.add(const PasswordlessSigninEvent.sendMagicLink());
},
skip: 1,
expect: () => [
PasswordlessSigninState(
emailAddress: some(right(validEmail)),
isSubmitting: true,
failureOrSuccessOption: none(),
),
PasswordlessSigninState(
emailAddress: some(right(validEmail)),
isSubmitting: false,
failureOrSuccessOption: some(
right(unit),
),
)
],
);

6. Test Open email app

Lets look at the code:

void _openMailApp(
_OpenMailApp event,
Emitter<PasswordlessSigninState> emit,
) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
// 1
launchUrl(Uri.parse('message://'));
} else if (defaultTargetPlatform == TargetPlatform.android) {
// 2
const AndroidIntent intent = AndroidIntent(
action: 'android.intent.action.MAIN',
category: 'android.intent.category.APP_EMAIL',
);
intent.launch();
}
}

6.1. launchUrl is a top level function and doesn’t belong to any class to mock it. We need to find a way to test it.

6.2. AndroidIntent is created inside the method and its not exposed outside the method so we can’t test it!

6.3. Based on running platform, it uses different approach.

_openMailApp is a black box method and we don’t see an obvious way to test it.

_openMailApp method has 2 different ways of launching email based on the platform. it uses launchUrl from url_launcher package for iOS and AndroidIntent from android_intent_plus package for Android. by sending openMailApp to PasswordlessSigninBloc there is no state change, so we can’t verify state change in the test.

void _openMailApp(
_OpenMailApp event,
Emitter<PasswordlessSigninState> emit,
) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
launchUrl(Uri.parse('message://'));
} else if (defaultTargetPlatform == TargetPlatform.android) {
const AndroidIntent intent = AndroidIntent(
action: 'android.intent.action.MAIN',
category: 'android.intent.category.APP_EMAIL',
);
intent.launch();
}
}

Tests are assertions, as this method doesn’t have any output it’s not possible to test it. So we need to update the method to be able to assert the interaction.

First step is to move opening mail app responsibility to another class, so this PasswordlessSigninBloc becomes simpler. We call that class MailAppLauncher and have only one method named launch .

Create a file in the utilities named mailapp_launcher.dart :

class MailAppLauncher {

MailAppLauncher({})

Future<void> launch() async {}
}

Then update PasswordlessSigninBloc :

final MailAppLauncher _mailAppLauncher;
PasswordlessSigninBloc(this._authenticator, this._mailAppLauncher)
...

void _openMailApp(
_OpenMailApp event,
Emitter<PasswordlessSigninState> emit,
) {
_mailAppLauncher.launch();
}

register MailAppLauncher inside getIt in the injection.dart file:

getIt.registerFactory<MailAppLauncher>(
() => MailAppLauncher(
platform: defaultTargetPlatform,
urlLauncher: launchUrl,
),
);

And then resolve the dependency of PasswordlessSigninBloc for the MailAppLauncher inside the routes.dart file.

For PasswordlessSigninBloc if we can verify _mailAppLauncher.launch called, it would be suffice for the test.

blocTest<PasswordlessSigninBloc, PasswordlessSigninState>(
'WHEN open mail app requested '
'THEN mail app launcher should be called',
build: () => makeSut(),
setUp: () {
when(
() => mailAppLauncher.launch(),
).thenAnswer((_) async => {});
},
act: (bloc) {
bloc.add(const PasswordlessSigninEvent.openMailApp());
},
verify: (_) => verify(mailAppLauncher.launch).called(1),
);

Now lets complete implementation of MailAppLauncher:

import 'package:android_intent_plus/android_intent.dart';
import 'package:flutter/foundation.dart';

typedef UrlLauncher = Future<bool> Function(Uri url);

class MailAppLauncher {
final AndroidIntent intent;
final TargetPlatform _platform;
final UrlLauncher _launchUrl;

MailAppLauncher({
required TargetPlatform platform,
required UrlLauncher urlLauncher,
this.intent = const AndroidIntent(
action: 'android.intent.action.MAIN',
category: 'android.intent.category.APP_EMAIL',
),
}) : _platform = platform,
_launchUrl = urlLauncher;

Future<void> launch() async {
if (_platform == TargetPlatform.android) {
intent.launch();
return;
} else {
await _launchUrl(Uri.parse('message://'));
return;
}
}
}

This class is simple, just passing launchUrl method and AndroidIntent through the constructor. Then we have control over dependencies and we can mock them easily.

Test MailAppLauncher

Create a test file named mail_app_launcher_test.dart in the test/utilities folder:

1. On iOS uses UrlLauncher

We just need to verify UrlLauncher with right URL on iOS has been called.

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/utilities/mailapp_launcher.dart';

void main() {
late MailAppLauncher sut;

tearDown(() => getIt.reset());

test('On iOS uses LaunchUrl', () {
// Given
final List<Uri> iOSLauncherMessages = [];
sut = MailAppLauncher(
platform: TargetPlatform.iOS,
urlLauncher: (uri) async {
iOSLauncherMessages.add(uri);
return true;
},
);
// When
sut.launch();
// Then
expect(iOSLauncherMessages, [Uri.parse('message://')]);
});

As you see in order to capture messages on urlLauncher with a List<Uri>. it has two benefits:

  1. Count number of times urlLauncher has been called.
  2. Verify right URL has passed to the method.

2. On Android should use AndroidIntent to launch the url:

Nothing special here, same as other tests.

import 'package:android_intent_plus/android_intent.dart';
import 'package:mocktail/mocktail.dart';

class MockAndroidIntent extends Mock implements AndroidIntent {}
...
group('On Android', () {
test('Uses AndroidIntent', () {
// Given
final intent = MockAndroidIntent();
sut = MailAppLauncher(
platform: TargetPlatform.android,
urlLauncher: (_) async {
return true;
},
intent: intent,
);
when(
() => intent.launch(),
).thenAnswer((_) async => {});

// When
sut.launch();

// Then
verify(intent.launch).called(1);
});
});

Next test is challenging, how we can be sure right settings are passed to the AndroidIntent? 🤔

MailAppLauncher({
required TargetPlatform platform,
required UrlLauncher urlLauncher,
this.intent = const AndroidIntent(
action: 'android.intent.action.MAIN',
category: 'android.intent.category.APP_EMAIL',
),
}) : _platform = platform,
_launchUrl = urlLauncher;

Maybe during initialization, incorrect values accidentally passed by someone, if there is not unit test for that, we can’t find out until we get report by the time the app is live! so we need to write a test for that to be sure AndroidIntent is properly configured.

3. AndroidIntent is properly configured for launching email app

As MailAppLauncher is constructed during app launch by calling configureInjection, we need to call configureInjection and then later retrieve MailAppLauncher and check its AndroidIntentSettings. So the test roughly can be like below:

import 'package:passwordless_signin/injection.dart';
...

test('AndroidIntent is properly configured for launching email app',
() async {

await configureInjection();

final intent = getIt<MailAppLauncher>().intent;
expect(intent.action == 'android.intent.action.MAIN', isTrue);
expect(intent.category == 'android.intent.category.APP_EMAIL', isTrue);
});
});

If we run the test, it fails:

Add TestWidgetsFlutterBinding.ensureInitialized(); to the start of the test and run again:

Another error!☹️ the cause of the error is that Firebase can’t open a method channel during test to connect with native code. we need to mock the Firebase method channel! so how?! for firebase its complicated. but there is an easy solution. Add below to your dev dependencies:

dev_dependencies:
firebase_core_platform_interface: ^4.8.0

firebase_core_platform_interface has a top level method to mock FirebaseCore named: setupFirebaseCoreMocks Add that method to your test after TestWidgetsFlutterBinding.ensureInitialized();

Run again and oh there is another error:

In general we should mock method channel of all dependencies that use method channel to communicate with native code. Lets do it for PackageInfo:

void setupPackageInfoPlusMethodChannelMock() {
const channel = MethodChannel('dev.fluttercommunity.plus/package_info');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
channel,
(MethodCall methodCall) async {
switch (methodCall.method) {
case 'getAll':
return <String, dynamic>{
'appName': 'sample_app_name',
};
default:
return null;
}
},
);
}

We are just saying if a method call named getAll requested in the PackageInfo method channel, pass the default payload. We got the method channel name and called method named based on looking at the above exception. also you can look at the package source code to find used method channel name.

Now lets look at the updated test, run and verify. Test will pass.

test('AndroidIntent is properly configured for launching email app',
() async {
TestWidgetsFlutterBinding.ensureInitialized();
setupPackageInfoPlusMethodChannelMock();
setupFirebaseCoreMocks();

await configureInjection();

final intent = getIt<MailAppLauncher>().intent;
expect(intent.action == 'android.intent.action.MAIN', isTrue);
expect(intent.category == 'android.intent.category.APP_EMAIL', isTrue);
});

You can view all commits until here by checking unit-tests tag:

 git checkout refs/tags/unit-tests

Testing Redirection — Widget Test

After writing test for functional core of the app, lets continue with routing. We have overridden the redirection part of the router. Proper testing of this part is crucial as it affects the app usage entirely. What if user is signed in, but not getting redirected to the home page?

First test is:

1. Routes to Sign-in page when user is not signed-in

So how we can test it? redirect callback depends on AuthProviderScope.of(context) and AuthProviderScope works while it is embedded in the widget tree, and AuthProviderScope is the first widget in the widget tree.

Does it mean for testing we need to build entire widget tree? the answer is Yes.

we can’t test redirect call back in isolation.

Flutter provide tools to build widget tree for testing purpose called widget testing. it has and introduction page in their documentation I recommend looking into.

Widget Testing Introduction

Create a file named routes_test.dart in test/routes path and same as before:

import 'dart:async';

import 'package:firebase_dynamic_links/firebase_dynamic_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:passwordless_signin/auth/email_secure_store.dart';
import 'package:passwordless_signin/auth/models/sign_in_link_settings.dart';
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/auth_provider_scope.dart';
import 'package:passwordless_signin/injection.dart';
import 'package:passwordless_signin/main.dart';
import 'package:passwordless_signin/utilities/mailapp_launcher.dart';

import '../auth/mocks/firebase_auth_spy.dart';

class MockFirebaseDynamicLinks extends Mock implements FirebaseDynamicLinks {}

class FakeEmailSecureStorage extends Fake implements EmailSecureStore {
@override
Future<String?> getEmail() async {
return 'myId@email.com';
}
}

class FakeMailAppLauncher extends Fake implements MailAppLauncher {
@override
Future<void> launch() async {}
}

void main() {
late FirebaseAuthSpy firebaseAuth;
late FirebaseDynamicLinks firebaseDynamicLinks;
late EmailSecureStore emailSecureStore;
late PasswordlessAuthenticator sut;

final signinLinkSettings = SignInLinkSettings(
url: 'https://a-url.com',
androidPackageName: 'com.name.android',
iOSBundleId: 'com.name.ios',
dynamicLinkDomain: 'subdomian.page.link',
);

setUp(() {
firebaseAuth = FirebaseAuthSpy();
firebaseDynamicLinks = MockFirebaseDynamicLinks();
emailSecureStore = FakeEmailSecureStorage();

sut = PasswordlessAuthenticator(
firebaseAuth,
firebaseDynamicLinks,
emailSecureStore,
signinLinkSettings,
);

getIt.registerLazySingleton<PasswordlessAuthenticator>(
() => sut,
);
getIt.registerLazySingleton<MailAppLauncher>(() => FakeMailAppLauncher());
});

tearDown(() {
getIt.reset();
});

group('Redirect', () {
testWidgets('Routes to Sign-in page when user is not signed-in',
(tester) async {

await tester.pumpWidget(
AuthProviderScope(
authNotifier: AuthNotifier(
sut,
),
child: const MyApp(),
),
);
expect(
find.text(
'Log in without a password!',
),
findsOneWidget,
);
});
});
}

We pump the entire app widget tree and after pumping finished, like a balloon which is filled and has a shape, we verify if we can read a text which is displayed on Sign-in page. Pump builds and run the passed widget tree. The first time when you run the app on the simulator, what you see? the sign in page. and what does the sign-in page show? a title and text field and continue button. here we just verify the title. verifying every details in the widget tree is not recommended and the right approach is using snapshot tests. we will cover snapshot testing later.

As you see above, all dependencies are full filled before pumping the widget tree.

setUp method will automatically be called before each test and tearDown gets called after each test gets finished (even with failure). tearDown used to clear any remaining footprints of tests as tests should be isolated of each other and should not affect their outcome.

2. Routes to verification page when sign-in is in progress

The next test is while sign-in is in progress, a loading indicator is displayed.

testWidgets('Routes to verification page when sign-in is in progress',
(tester) async {
// Given
final signinLink = Uri.https('sigin-link.com');

when(() => firebaseDynamicLinks.onLink).thenAnswer(
(_) => StreamController<PendingDynamicLinkData>().stream,
);
when(() => firebaseDynamicLinks.getInitialLink())
.thenAnswer((_) async => PendingDynamicLinkData(link: signinLink));

when(() => firebaseAuth.currentUser).thenReturn(null);
when(() => firebaseAuth.isSignInWithEmailLink(signinLink.toString()))
.thenReturn(true);

// When
sut.checkEmailLink();

await tester.pumpWidget(
AuthProviderScope(
authNotifier: AuthNotifier(
sut,
),
child: const MyApp(),
),
);
await tester.pump();

// Then
expect(
find.byType(CircularProgressIndicator),
findsOneWidget,
);
});

As you see before pumping the widget we need to mock all inner dependencies inside checkEmailLink to be able to simulate displaying loading page.

As you see we have called await tester.pump() after pumping the widget. its like refreshing the widget tree to display any pending UI changes.

And the last test in redirection is:

3. Routes to Home page when user is signed-in

testWidgets('Routes to Home page when user is signed-in', (tester) async {

firebaseAuth.completeSigninWithLinkWithSuccess();

await tester.pumpWidget(
AuthProviderScope(
authNotifier: AuthNotifier(
sut,
),
child: const MyApp(),
),
);
expect(
find.text(
'Welcome!',
),
findsOneWidget,
);
});

We just call firebaseAuth.completeSigninWithLinkWithSuccess() to simulate a successful sign-in and then we expect to see HomePage.

Lets run tests with code coverage again:

We could cover 76.7% of the entire app, sounds good 👍. Those red ones, are widgets that we couldn’t test in isolation and we need to test them in integration with other components.

By writing widget tests for redirection part of the router, we could cover other parts of the app. Widget tests are higher levels tests and will cover more parts of the app as it requires multiple parts to be integrated to be able to run.

You can view the code by checking unit-test-end tag:

git checkout unit-test-end

But how we can be sure the app as a whole covers the business requirement?

So in the fourth and last part we answer the above question by writing acceptance tests.

Part 4: Writing Acceptance Tests

Will see you in the next part 😉

--

--