A Complete Guide to Testing in Flutter. Part 4: Advanced Unit Testing.
Credit: Nguyễn Thành Minh (Mobile Developer)
Introduction
In the previous article, we learned how to use Mocking and Stubbing techniques to test classes that depend on other classes. In this article, we will further increase the complexity of the LoginViewModel
class by creating a _cache
variable to cache results obtained from SharedPreferences
. When calling the login
function, we will prioritize retrieving data from the cache.
import 'package:shared_preferences/shared_preferences.dart';
class LoginViewModel {
final SharedPreferences sharedPreferences;
LoginViewModel({
required this.sharedPreferences,
});
final Map<String, String?> _cache = {};
bool login(String email, String password) {
if (_cache.containsKey(email)) {
return password == _cache[email];
}
final storedPassword = sharedPreferences.getString(email);
_cache[email] = storedPassword;
return password == storedPassword;
}
}
The code above has an issue: the variable _cache
is private, so we can not mock it. Therefore, we have only one test scenario when _cache
is empty. How can we add more values to _cache
to test various scenarios while keeping it private? That's when we need the @visibleForTesting
annotation.
Annotation @visibleForTesting
final Map<String, String?> _cache = {};
// Expose this method for testing purposes to set values in _cache
@visibleForTesting
void putToCache(String email, String? password) {
_cache[email] = password;
}
When a function is marked with this annotation, we intend to imply that the function should only be used in test files and within the file containing the function. Thus, in essence, the function remains private.
Now, we can write tests for two testing scenarios as follows:
group('login', () {
test('login should return true when the cache contains the password input even when the password is incorrect', () {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
String email = 'ntminh@gmail.com';
String password = 'abc';
loginViewModel.putToCache(email, 'abc'); // NEW
// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');
// Act
final result = loginViewModel.login(email, password);
// Assert
expect(result, true);
});
test('login should return false when the cache does not contain the password input and the password is incorrect', () {
// Arrange
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
String email = 'ntminh@gmail.com';
String password = 'abc';
// Stubbing
when(() => mockSharedPreferences.getString(email)).thenReturn('123456');
// Act
final result = loginViewModel.login(email, password);
// Assert
expect(result, false);
});
});
We’ve tested cases related to _cache
. Next, let's try refactoring a bit to avoid duplicate code by moving the initialization of mockSharedPreferences
and loginViewModel
out of the test
functions like this.
When we run the test again, we will see that the second test case has an error.
Why is the actual true
instead of false
?
When sharing the loginViewModel
object, we're also sharing the _cache
variable. In the first test case, we put a value into _cache
with loginViewModel.putToCache(email, 'abc');
, so when we move to the second test case, _cache
already contains the value 'abc'
, thus _cache
contains the password input and returns true
.
To fix the bug, we need to make sure that each time a new test case runs, we create a new instance of loginViewModel
. We can accomplish this by using the setUp
function.
void main() {
late MockSharedPreferences mockSharedPreferences;
late LoginViewModel loginViewModel;
setUp(() {
mockSharedPreferences = MockSharedPreferences();
loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
});
...
}
Let’s run the test again, we will see all the tests passed.
setUp, tearDown, setUpAll, tearDownAll functions
- setUp
The setUp
function is executed before running each test case, so it is commonly used to initialize test objects and configure initial values. In the above example, the execution order of the functions is as follows:
setUp (initialization) -> test case 1 -> setUp (re-initialization) -> test case 2
By that way, the loginViewModel
is re-initialized before running test case 2, so the bug is fixed.
- tearDown
The tearDown
function is executed after each test case completes, typically used for cleanup tasks such as memory deallocation, closing resources, closing database…
- setUpAll
The setUpAll
function is executed only once before running all tests, so it is often used to create or open a database and use that same database for all tests.
setUpAll(() async {
await Isar.initializeIsarCore(download: true);
isar = await Isar.open([JobSchema], directory: '');
});
- tearDownAll
The tearDownAll
function is executed only once after all tests have finished running, so it is often used to close the database.
tearDownAll(() async {
await isar.close();
});
Let’s go through some examples to understand how to apply these 4 functions. Besides, in this example, we will try to test Stream
s.
Testing Streams
Let’s say our app uses Isar database and we have a table named JobData
.
import 'package:isar/isar.dart';
part 'job_data.g.dart';
@collection
class JobData {
Id id = Isar.autoIncrement;
late String title;
}
We have a class HomeBloc
. In this class, we will listen to the data returned from Isar.
class HomeBloc {
HomeBloc({required this.isar}) {
_streamSubscription = isar.jobDatas.where().watch(fireImmediately: true).listen((event) {
_streamController.add(event);
});
}
final Isar isar;
final _streamController = StreamController<List<JobData>>.broadcast();
StreamSubscription? _streamSubscription;
Stream<List<JobData>> get data => _streamController.stream;
void close() {
_streamSubscription?.cancel();
_streamController.close();
_streamSubscription = null;
}
}
Next, we will create a test file to test the getter data
of HomeBloc
class.
After that, we will initialize HomeBloc
in the setUp
function and initialize the Isar Database in the setUpAll
function. Typically, if we initialize an object in the setUp
function, we will clean up that object in the tearDown
function. Conversely, if we initialize it in the setUpAll
function, we will perform cleanup in the tearDownAll
function.
void main() {
late Isar isar;
late HomeBloc homeBloc;
setUp(() async {
await isar.writeTxn(() async => isar.clear());
homeBloc = HomeBloc(isar: isar);
});
tearDown(() {
homeBloc.close();
});
setUpAll(() async {
await Isar.initializeIsarCore(download: true);
isar = await Isar.open(
[
JobDataSchema,
],
directory: '',
);
});
tearDownAll(() {
isar.close();
});
}
Finally, we write tests for getter data
.
test('data should emit what Isar.watchData emits', () async {
expectLater(
homeBloc.data,
emitsInOrder([
[],
[JobData()..title = 'IT'],
[JobData()..title = 'IT', JobData()..title = 'Teacher'],
]));
// put data to Isar
await isar.writeTxn(() async {
isar.jobDatas.put(JobData()..title = 'IT');
});
await isar.writeTxn(() async {
isar.jobDatas.put(JobData()..title = 'Teacher');
});
});
There are two new things here:
- The
expectLater
function: This function is different fromexpect
function in thatexpect
is used to test a synchronous value, whileexpectLater
is used to test an asynchronous value such asStream
. When we want to test a stream, theexpectLater
statement must be placed before the stream emits the event. This way, we can observe the values as the stream emits them. In such a case, do not useawait
before theexpectLater()
function, otherwise the test will fail. - The
emitsInOrder
function: is a matcher used to test whether aStream
emits events in the correct order. In addition, if we want to test events but not in order, use theemitsInAnyOrder
matcher.
The resetMocktailState function
This function is used to reset mock objects. For the above error, we can call this function in the setUp
or tearDown
function to fix the error instead of re-initializing the mock object in the setUp
function.
tearDown(() {
resetMocktailState();
});
Conclusion
In this article, we learned how to write advanced test cases. We will continue to learn more about the mocktail
library to write tests for more complex cases in the next section.