A Complete Guide to Testing in Flutter. Part 4: Advanced Unit Testing.

NALSengineering
6 min readJun 4, 2024

--

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);
});

...
}

Full source code

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 Streams.

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 from expect function in that expect is used to test a synchronous value, while expectLater is used to test an asynchronous value such as Stream. When we want to test a stream, the expectLater 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 use await before the expectLater() function, otherwise the test will fail.
  • The emitsInOrder function: is a matcher used to test whether a Stream emits events in the correct order. In addition, if we want to test events but not in order, use the emitsInAnyOrder matcher.

Full source code

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.

--

--

NALSengineering

Knowledge sharing empowers digital transformation and self-development in the daily practices of software development at NAL Solutions.