A Complete Guide to Testing in Flutter. Part 6: Test Doubles: Fakes vs Mocks.

NALSengineering
6 min readJun 6, 2024

--

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

Full source code

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

Full source code

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.

--

--

NALSengineering

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