A Complete Guide to Testing in Flutter. Part 6: Test Doubles: Fakes vs Mocks.
Credit: Nguyễn Thành Minh (Mobile Developer)
Faking
In the example from the previous lesson, what happens if we add a parameter of type BuildContext
to the push
function?
import 'package:flutter/material.dart';
class LoginViewModel {
final Navigator navigator;
LoginViewModel({
required this.navigator,
});
void login(BuildContext context, String email) {
if (email.isNotEmpty) {
navigator.push(context, 'home');
}
}
}
class Navigator {
void push(BuildContext context, String name) {}
}
Then, we need to update the test like this:
void main() {
...
setUpAll(() {
registerFallbackValue(BuildContext());
});
...
group('login', () {
test('navigator.push should be called once when the email is not empty', () {
...
loginViewModel.login(BuildContext(), email);
verify(() => mockNavigator.push(any(), any())).called(1);
});
});
}
But BuildContext
is an abstract class, so how can we initialize BuildContext()
like that?
At this point, we need to create a new fake type by extending the Fake
class.
class FakeBuildContext extends Fake implements BuildContext {}
Instead of creating a real object like BuildContext()
, we only need to create a fake object like FakeBuildContext()
.
registerFallbackValue(FakeBuildContext());
...
loginViewModel.login(FakeBuildContext(), email);
However, if we extend the Mock
class instead of the Fake
class, the test still passes. So, what is the difference between Fake
and Mock
?
Faking vs Mocking
Both terms Fake and Mock are called “Test Double.” Test Double is a general term used to refer to objects that replace real objects used during testing. In other words, both techniques are used to create fake classes and fake objects. Additionally, both are used to simulate the methods of fake objects and control the return results of their methods.
If the Mock technique uses Stub functions to simulate and control the output of methods, then with Fake, we cannot use Stub functions. Instead, Fake allows us to override the methods of the real class in the way we want to test.
We will write the test again for the LoginViewModel class from section 3, but we will use the Faking technique instead of Mocking.
First, we will create a class FakeSharedPreferences
that extends Fake
. If when using Mock, we need to stub the getString
and clear
methods, then when using Fake, we need to override them.
class FakeSharedPreferences extends Fake implements SharedPreferences {
@override
String? getString(String key) {
if (key == 'ntminh@gmail.com') {
return '123456';
}
return null;
}
@override
Future<bool> clear() {
return Future.value(true);
}
}
Next, we just need to remove the lines of code that use stubbing.
test('login should return false when the password are incorrect', () {
// Arrange
final fakeSharedPreferences = FakeSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: fakeSharedPreferences);
String email = 'ntminh@gmail.com';
String password = 'abc';
// Stubbing -> remove this line
// when(() => mockSharedPreferences.getString(email)).thenReturn('123456');
// Act
final result = loginViewModel.login(email, password);
// Assert
expect(result, false);
});
However, when it runs this case, it fails.
test('logout should throw an exception when the clear method returns false', () async {
// Arrange
final fakeSharedPreferences = FakeSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: fakeSharedPreferences);
// Stubbing -> remove this line
// when(() => mockSharedPreferences.clear()).thenAnswer((_) => Future.value(false));
// Act
final Future<bool> Function() call = loginViewModel.logout;
// Assert
expect(call, throwsFlutterError);
});
The reason is that I overrode the clear
function to return Future.value(true)
, but here we expect that it returns Future.value(false)
. Therefore, we cannot use the FakeSharedPreferences
class to test this case. Instead, we have to create a new class to override the clear
function to return Future.value(false)
.
class SecondFakeSharedPreferences extends Fake implements SharedPreferences {
@override
String? getString(String key) {
if (key == 'ntminh@gmail.com') {
return '123456';
}
return null;
}
@override
Future<bool> clear() {
return Future.value(false);
}
}
Then, for the failing case above, we will use the SecondFakeSharedPreferences
class.
final fakeSharedPreferences = SecondFakeSharedPreferences();
As we can see, when we use Faking, we may need to create multiple Fake
classes to achieve that. That’s also a drawback of using Fake. So, what are the advantages of Fake?
To see the advantages of Fake, let’s move on to another example. Suppose we have the JobViewModel
class, and JobRepository
and JobData as follows:
class JobRepository {
final Isar isar;
JobRepository({required this.isar});
Future<void> addJob(JobData jobData) async {
await isar.writeTxn(() async {
isar.jobDatas.put(jobData);
});
}
Future<void> updateJob(JobData jobData) async {
await isar.writeTxn(() async {
isar.jobDatas.put(jobData);
});
}
Future<void> deleteJob(int id) async {
await isar.writeTxn(() async {
isar.jobDatas.delete(id);
});
}
Future<List<JobData>> getAllJobs() async {
return await isar.jobDatas.where().findAll();
}
}
This is JobViewModel
class.
class JobViewModel {
JobRepository jobRepository;
JobViewModel({required this.jobRepository});
final Map<int, JobData> jobMap = {};
Future<void> addJob({
required JobData jobData,
}) async {
await jobRepository.addJob(jobData);
}
Future<void> updateJob({
required JobData jobData,
}) async {
await jobRepository.updateJob(jobData);
}
Future<void> deleteJob(int id) async {
await jobRepository.deleteJob(id);
}
Future<void> getAllJobs() async {
final jobs = await jobRepository.getAllJobs();
jobMap.clear();
for (var post in jobs) {
jobMap[post.id] = post;
}
}
}
Now, let’s write a test for the JobViewModel
class.
First, we need to create the FakeJobRepository
class. We’ll create a variable named jobDataInDb
of type List<JobData>
to simulate real data in the Isar Database. Then, we’ll have to override all 4 methods of the JobRepository
.
class FakeJobRepository extends Fake implements JobRepository {
// Suppose initially there are 3 jobs in the database.
final jobDataInDb = [
JobData()..id = 1..title = 'Job 1',
JobData()..id = 2..title = 'Job 2',
JobData()..id = 3..title = 'Job 3',
];
@override
Future<void> addJob(JobData jobData) async {
jobDataInDb.add(jobData);
}
@override
Future<void> updateJob(JobData jobData) async {
jobDataInDb.firstWhere((element) => element.id == jobData.id).title = jobData.title;
}
@override
Future<void> deleteJob(int id) async {
jobDataInDb.removeWhere((element) => element.id == id);
}
@override
Future<List<JobData>> getAllJobs() async {
return jobDataInDb;
}
}
Next, we will test the addJob
function in the JobViewModel
class.
group('addJob', () {
test('should add job to jobMap', () async {
// before adding job
await jobViewModel.getAllJobs();
expect(jobViewModel.jobMap, {
1: JobData()..id = 1..title = 'Job 1',
2: JobData()..id = 2..title = 'Job 2',
3: JobData()..id = 3..title = 'Job 3',
});
await jobViewModel.addJob(jobData: JobData()..id = 4..title = 'Job 4');
// after adding job
await jobViewModel.getAllJobs();
expect(jobViewModel.jobMap, {
1: JobData()..id = 1..title = 'Job 1',
2: JobData()..id = 2..title = 'Job 2',
3: JobData()..id = 3..title = 'Job 3',
4: JobData()..id = 4..title = 'Job 4',
});
});
});
See more test cases here.
As we can see, we not only test individual functions like getAllJobs
and addJob
, but also test cases where these functions work with each other. This helps make the testing more similar to running in a real environment.
If we were to use Mock and Stub to test the addJob
function, the code would look like this:
test('should add job to jobMap', () async {
// Arrange
final jobData = JobData()..id = 4..title = 'Job 4';
// Stub
when(() => mockJobRepository.addJob(jobData)).thenAnswer((_) async {});
// Act
await jobViewModel.addJob(jobData: jobData);
// Assert
verify(() => mockJobRepository.addJob(jobData)).called(1);
});
With this approach, we won’t determine whether the addJob
function runs correctly or not. Alternatively, you might code like this:
test('should add job to jobMap', () async {
final jobData = JobData()..id = 4..title = 'Job 4';
// Stub
when(() => mockJobRepository.addJob(jobData)).thenAnswer((_) async {});
when(() => mockJobRepository.getAllJobs()).thenAnswer((_) async {
return [
JobData()..id = 1..title = 'Job 1',
JobData()..id = 2..title = 'Job 2',
JobData()..id = 3..title = 'Job 3',
JobData()..id = 4..title = 'Job 4',
];
});
// Act
await jobViewModel.addJob(jobData: jobData);
await jobViewModel.getAllJobs();
// Assert
expect(jobViewModel.jobMap, {
1: JobData()..id = 1..title = 'Job 1',
2: JobData()..id = 2..title = 'Job 2',
3: JobData()..id = 3..title = 'Job 3',
4: JobData()..id = 4..title = 'Job 4',
});
});
When we’ve stubbed to return 4 JobData
, then definitely the result in the expect
statement will also be 4 JobData
. Therefore, we still can’t determine whether the addJob
function runs correctly or not.
In summary, using Fake will be more effective than using Mock in these cases like this.
Conclusion
Through this article, we have learned about a new technique in testing called Faking. I will share with you best practices in testing.