Faking vs. Mocking
Hello, Amigos! This is Pavel Gershevich, Mobile Team Lead of the Amiga product development agency. We have already covered more than half of the guide on testing in Flutter! Today article translation is dedicated to the Faking technique. And in the following parts we will consider common mistakes and best practices in writing Unit tests. So stay tuned!
New releases, useful plugins and libraries, cases and personal experience in our author's telegram channel Flutter. Many. Our community already has 2645 mobile developers, join us!
Faking
What will happen in the example from previous articleif you add a parameter of type BuildContext to the function push
?
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, you 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
– abstract class, then how can it be initialized?
At this stage, it is necessary to create a new fake type by extending the class Fake
.
class FakeBuildContext extends Fake implements BuildContext {}
Instead of creating a real object BuildContext()
you only need to create a fake object FakeBuildContext()
.
registerFallbackValue(FakeBuildContext());
...
loginViewModel.login(FakeBuildContext(), email);
However, if you inherit from a class Mock
instead of Fake
then the test will still pass. So what is the difference between Fake
And Mock
?
Faking vs. Mocking
The terms Fake and Mock are called “Test Doubles”. Test doubles are objects that replace real ones during testing. In other words, both techniques are used to create fake classes and objects. They are also used to simulate the methods of fake objects and to control the return values of these methods.
While the Mocking technique uses Stubbing to simulate and control the result of functions, it cannot be used with Faking. Faking allows you to override the methods of a real class in the form needed for testing.
Let's write tests for the LoginViewModel class again from parts 3but we will use the Faking technique instead of Mocking.
First, let's create a class FakeSharedPreferences
which is inherited from Fake
. If you use Mock, you need to replace the methods getString
And clear
but when using Faking they need to be overridden.
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 will 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 you run the test, it will crash.
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);
});
This happened because of a function override. clear
for return Future.value(true)
but is expected to return Future.value(false)
. So there is no need to use the class FakeSharedPreferences
to test this test case. Instead, let's create a new class to override the function clear
so that she would 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 test shown above we will use the class SecondFakeSharedPreferences
.
final fakeSharedPreferences = SecondFakeSharedPreferences();
You can see when using Faking, you can create multiple classes Fake
to achieve this. This is the disadvantage of using Faking. What are the advantages of Faking?
To understand the benefits of Faking, let's move on to another example. Let's say there are classes JobViewModel
, JobRepository
And JobData:
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 class JobViewModel
.
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 it.
First you need to create a class FakeJobRepository
. Let's create a variable jobDataInDb
with type List<JobData>
to simulate real data in the Isar database. Then you can override all 4 methods in 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 function addJob
in class JobViewModel
.
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",
});
});
});
More test cases can be found Here.
Thus, not only individual functions by type were tested getAllJobs
And addJob
but also test cases where these functions work together. This helps make testing more similar to running in a real environment.
If you use Mocking and Stubbing to test a function addJob
then the code will 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 it is not determined whether the function works correctly addJob
or not. Alternatively, you can write the 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 it was replaced by 4 JobData
then, definitely, the result in the expression expect
there will also be 4 JobData
. Therefore, there is no need to determine whether the function works correctly. addJob
or not.
To sum it up, using Faking can be more effective than Mocking in cases like this.
In the next article we will look at the most common mistakes in testing.
Subscribe to telegram channel Flutter. Manyso you don't miss the new issue and much more interesting information about cross-platform development.