A Complete Guide to Testing in Flutter. Part 3: Mocking and Stubbing.
Credit: Nguyễn Thành Minh (Mobile Developer)
Introduction
In the previous section, we were able to write unit tests for static functions, top-level functions, and extension methods. In today’s lesson, we will write unit tests for class methods.
Writing Unit Tests for Class Method
I will use the example from the previous parts, but I will create the LoginViewModel
class instead.
import 'package:shared_preferences/shared_preferences.dart';
class LoginViewModel {
bool login(String email, String password) {
return Validator.validateEmail(email) && Validator.validatePassword(password);
}
}
We only need 2 test cases for example:
group('login', () {
test('login should return false when the email and password are invalid', () {
final loginViewModel = LoginViewModel();
final result = loginViewModel.login('', '');
expect(result, false);
});
test('login should return true when the email and password are valid', () {
final loginViewModel = LoginViewModel();
final result = loginViewModel.login('ntminh@gmail.com', 'password123');
expect(result, true);
});
});
At this point, there is still no difference from the previous parts. Now, I will add a SharedPreferences
object to LoginViewModel
and update the logic of the login
function.
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LoginViewModel {
final SharedPreferences sharedPreferences;
LoginViewModel({
required this.sharedPreferences,
});
bool login(String email, String password) {
final storedPassword = sharedPreferences.getString(email);
return password == storedPassword;
}
Future<bool> logout() async {
bool success = false;
try {
success = await sharedPreferences.clear();
} catch (e) {
success = false;
}
if (!success) {
throw FlutterError('Logout failed');
}
return success;
}
}
We can see that the output of the login
function depends on the output of the sharedPreferences.getString(email)
function. Therefore, depending on the return results of the sharedPreferences.getString(email)
function, we will have the following test cases:
- The function
sharedPreferences.getString(email)
returnstoredPassword
is different from thepassword
passed to thelogin
function. - The function
sharedPreferences.getString(email)
returnstoredPassword
is equal to thepassword
passed to thelogin
function.
To control the results returned from the sharedPreferences.getString(email)
function, we need to use Mocking and Stubbing techniques.
Mocking and Stubbing
Mocking is creating fake objects that stand in for real objects. We often use mocking to mock the dependencies of the object that needs to be tested. Besides, we can control the return results of mock objects’ methods. This technique is called Stubbing. For example, we can mock the ApiClient
object and stub its get
, post
, put
, delete
methods to return dummy data instead of making actual calls.
In our example, we need to mock the SharedPreferences
object to avoid calling the clear
or getString
functions in reality, and more importantly, it helps us simulate the return result of the getString
function. From there, we can create multiple test scenarios for the login
function.
There are two popular libraries that allow us to use Mocking and Stubbing techniques: mocktail and mockito. In this series, I’ll use the mocktail library.
First, we need to add the package mocktail
to dev_dependencies
dev_dependencies:
mocktail: 1.0.3
Next, we need to create a class named MockSharedPreferences
that extends the Mock
class and implements the SharedPreferences
class.
class MockSharedPreferences extends Mock implements SharedPreferences {}
Then, we will create a mock object inside the main
function.
final mockSharedPreferences = MockSharedPreferences();
After that, we can simulate mockSharedPreferences
to return a dummy password of 123456
using the stubbing technique.
// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');
Finally, we can test the case when the user enters the wrong password by simulating the function sharedPreferences.getString(email)
returning storedPassword
that is different from the password
passed to the login
function.
test('login should return false when the password are incorrect', () {
// Arrange
final mockSharedPreferences = MockSharedPreferences(); // create mock object
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
String email = 'ntminh@gmail.com';
String password = 'abc'; // incorrect password
// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');
// Act
final result = loginViewModel.login(email, password);
// Assert
expect(result, false);
});
Similarly, we can also test the case where the user enters the correct password.
test('login should return false when the password are correct', () {
// Arrange
final mockSharedPreferences = MockSharedPreferences(); // create mock object
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
String email = 'ntminh@gmail.com';
String password = '123456'; // correct password
// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');
// Act
final result = loginViewModel.login(email, password);
// Assert
expect(result, true);
});
Mocktail provides us with 3 ways of stubbing:
when(() => functionCall()).thenReturn(T expected)
is used whenfunctionCall
is not an asynchronous function like the example above.when(() => functionCall()).thenAnswer(Answer<T> answer)
is used whenfunctionCall
is an asynchronous function. For example, to stubbing theclear
function, we do the following:
when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(true));
when(() => functionCall()).thenThrow(Object throwable)
is used when we want thefunctionCall
throw an exception. For example:
when(() => mockSharedPreferences.clear()).thenThrow(Exception('Clear failed'));
Now, we will use stubbing methods to test the logout
function in 3 test scenarios.
group('logout', () {
test('logout should return true when the clear method returns true', () async {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
// Stubbing
when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(true));
// Act
final result = await loginViewModel.logout();
// Assert
expect(result, true);
});
test('logout should throw an exception when the clear method returns false', () async {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
// Stubbing
when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(false));
// Act
final call = loginViewModel.logout;
// Assert
expect(call, throwsFlutterError);
});
test('logout should throw an exception when the clear method throws an exception', () async {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
// Stubbing
when(() => mockSharedPreferences.clear()).thenThrow(Exception('Logout failed'));
// Act
final Future<bool> Function() call = loginViewModel.logout;
// Assert
expect(
call,
throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')),
);
});
});
There are a few new things in the above code:
- When we expect to throw an error instead of a result, in the Act step, we are not allowed to invoke the
logout
method because invoking the function would cause any errors thrown within thelogout
method to be thrown to the test function, resulting in failing the test. We can only create a variable with a function type like this:
final Future<bool> Function() call = loginViewModel.logout;
- When we expect to throw an error instead of a result, we can use available Matchers like
throwsArgumentError
,throwsException
, etc. In the example above, I expect it will throw aFlutterError
error so I will useexpect(call, throwsFlutterError)
.
- When we want to assert in a more specific and detailed way. For example, expect the error thrown must be
FlutterError
and itsmessage
must be “Logout failed”. Then, we need to use two matchers:throwsA
andisA
.
expect(
call,
throwsA(isA<FlutterError>().having((e) => e.message, 'error message', 'Logout failed')),
);
- Matcher
throwsA<T>()
allows us to test to throw any error including custom exception classes that we create. In fact,throwsFlutterError
is equivalent tothrowsA(isA FlutterError())
. - The
isA<T>()
matcher stands for a result of typeT
without caring about its specific value. For instance, when we expect the test to return eithertrue
orfalse
, as long as it's a boolean type, we can use:expect(result, isA<bool>())
. It's often paired with itshaving
method to conduct more detailed checks beyond just the data type. For instance:isA<FlutterError>().having((e) => e.message, 'description: error message', 'Logout failed')
is the same as requiring an object to be of typeFlutterError
and itsmessage
property to be 'Logout failed'.
Conclusion
In this article, we’ve learned the Mocking and Stubbing techniques along with some common functions like throwsA
, isA
, having
. I believe that we could write unit tests from simple to complex by using these techniques.