Passwordless sign-in with Firebase in Flutter — Part 3 — Writing Unit Tests
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:
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.
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.
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.
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.
- 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:
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:
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:
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
.
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
.
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:
- Count number of times
urlLauncher
has been called. - 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.
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 😉