I'm trying to write the code of the ApplicationState in Get to know Firebase for Flutter codelab while practicing Test Driven Development. The method signOut in the codelab should be like this:
void signOut() {
// The question is about how to test the effect of this invocation
FirebaseAuth.instance.signOut();
}
If I understand it right, FirebaseAuth.instance.signOut()
should make FirebaseAuth.instance.userChanges()
invoke its listener with Stream<User?>
that contains a null
User
. So the effect of invoking FirebaseAuth.instance.signOut()
is not direct. I don't know how to mock this. According to Test Driven Development, I should never write a code unless I do that to let (a failing test) pass.
The problem is how to write a failing test that forces me to write the following code:
Future<void> init() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FirebaseAuth.instance.userChanges().listen((user) {
if (user != null) {
_loginState = ApplicationLoginState.loggedIn;
}
////////////////////////////////////////////////////////////
else {
_loginState = ApplicationLoginState.loggedOut; ////
}
////////////////////////////////////////////////////////////
notifyListeners();
});
}
I can mock FirebaseAuth.instance.signOut()
like this:
// firebaseAuth is a mocked object
when(firebaseAuth.signOut())
.thenAnswer((realInvocation) => Completer<void>().future);
// sut (system under test)
sut.signOut();
verify(firebaseAuth.signOut()).called(1);
And this forces me to invoke FirebaseAuth.instance.signOut()
. But this doesn't force me to write the aforementioned code to let it pass.
I test this code:
Future<void> init() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FirebaseAuth.instance.userChanges().listen((user) {
/////////////////////////////////////////////////////////////
if (user != null) {
_loginState = ApplicationLoginState.loggedIn; ////
}
/////////////////////////////////////////////////////////////
else {
_loginState = ApplicationLoginState.loggedOut;
}
notifyListeners();
});
}
By mocking FirebaseAuth.instance.signInWithEmailAndPassword()
:
final userCredential = MockUserCredential();
// firebaseAuth is a mocked object
when(firebaseAuth.signInWithEmailAndPassword(
email: validEmail, password: password))
.thenAnswer((realInvocation) => Future.value(userCredential));
// sut (system under test)
await sut.signInWithEmailAndPassword(
validEmail, password, firebaseAuthExceptionCallback);
// This is the direct effect on my class, that will happen by the aforementioned code
expect(sut.loginState, ApplicationLoginState.loggedIn);
Please, be patient with me. I'm new to doing testing and Test Driven Development.
Here is what I did.
Instead of calling FirebaseAuth.instance.userChanges().listen()
once in the init() method as in the codelab, I called it twice, one time on the signInWithEmailAndPassword() method, and one time on the signOut() method.
Future<void> signInWithEmailAndPassword(String email, String password,
void Function(FirebaseAuthException exception) errorCallback) async {
///////////////////////////////////////////////////////////////////
firebaseAuth.userChanges().listen(_whenNotNullUser); ////
///////////////////////////////////////////////////////////////////
try {
await firebaseAuth.signInWithEmailAndPassword(
email: email, password: password);
} on FirebaseAuthException catch (exception) {
errorCallback(exception);
}
}
Future<void> signOut() async {
///////////////////////////////////////////////////////////////////
firebaseAuth.userChanges().listen(_whenNullUser); ////
///////////////////////////////////////////////////////////////////
await firebaseAuth.signOut();
}
void _whenNullUser(User? user) {
if (user == null) {
_loginState = ApplicationLoginState.loggedOut;
notifyListeners();
}
}
void _whenNotNullUser(User? user) {
if (user != null) {
_loginState = ApplicationLoginState.loggedIn;
notifyListeners();
}
}
And this is my test:
test("""
$given $workingWithApplicationState
$wheN Calling signInWithEmailAndPassword()
$and Calling loginState returns ApplicationLoginState.loggedIn
$and Calling signOut()
$and Calling loginState returns ApplicationLoginState.loggedOut
$and Calling signInWithEmailAndPassword()
$then Calling loginState should return ApplicationLogginState.loggedIn
$and $notifyListenersCalled
""", () async {
when(firebaseAuth.signInWithEmailAndPassword(
email: validEmail, password: password))
.thenAnswer((realInvocation) => Future.value(userCredential));
await sut.signInWithEmailAndPassword(
validEmail, password, firebaseAuthExceptionCallback);
expect(sut.loginState, ApplicationLoginState.loggedIn);
reset(notifyListenerCall);
prepareUserChangesForTest(nullUser);
await sut.signOut();
verify(firebaseAuth.signOut()).called(1);
expect(sut.loginState, ApplicationLoginState.loggedOut);
reset(notifyListenerCall);
prepareUserChangesForTest(notNullUser);
when(firebaseAuth.signInWithEmailAndPassword(
email: validEmail, password: password))
.thenAnswer((realInvocation) => Future.value(userCredential));
await sut.signInWithEmailAndPassword(
validEmail, password, firebaseAuthExceptionCallback);
expect(sut.loginState, ApplicationLoginState.loggedIn);
verify(notifyListenerCall()).called(1);
});
Now I'm forced to write the login in both _whenNullUser() and _whenNotNullUser() methods to pass my test.