Bách khoa toàn thư về Test trong Flutter. Tập 6: Test Doubles: Fakes vs Mocks.

NALSengineering
7 min readJan 27, 2024
Source: Unsplash

Credit: Nguyễn Thành Minh (Mobile Developer)

Kỹ thuật Faking

Trong ví dụ ở bài trước, điều gì xảy ra nếu chúng ta thêm param kiểu BuildContext vào hàm 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) {}
}

Khi đó, chúng ta cần update lại test như này:

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

Nhưng BuildContext là một abstract class thì làm sao có thể khởi tạo BuildContext() như vậy được?

Lúc này, chúng ta cần ra một type giả mới bằng cách extends class Fake

class FakeBuildContext extends Fake implements BuildContext {}

Như vậy, thay vì khởi tạo object thật là BuildContext() thì chúng ta chỉ cần khởi tạo object fake là FakeBuildContext()

registerFallbackValue(FakeBuildContext());

...

loginViewModel.login(FakeBuildContext(), email);

Full source code

Tuy nhiên, nếu chúng ta extends class Mock thay vì class Fake thì test vẫn pass. Như vậy, sự khác nhau giữa FakeMock là gì?

Faking vs Mocking

Cả 2 thuật ngữ Fake và Mock được gọi chung là “Test Double”. Test Double là một thuật ngữ chung được sử dụng để chỉ các object thay thế các object thật được sử dụng trong quá trình test. Nói cách khác, cả 2 kỹ thuật này đều được sử dụng để tạo ra các class giả và các object giả. Ngoài ra, cả hai đều được sử dụng để giả lập các method của các object giả và kiểm soát kết quả trả về của các method của chúng.

Nếu như, kỹ thuật Mock sử dụng các hàm Stub để giả lập các method thì đối với Fake, ta không thể sử dụng các hàm Stub được. Thay vào đó, Fake cho phép chúng ta override lại các method của class thật và code lại theo cách mà chúng ta muốn test.

Bây giờ, chúng ta sẽ thử viết lại test cho class LoginViewModel trong tập 3 nhưng lần này chúng ta sẽ sử dụng kỹ thuật Faking thay vì Mocking.

Đầu tiên, ta sẽ tạo class FakeSharedPreferences extends Fake. Nếu khi sử dụng Mock, ta cần phải stub các method getStringclear thì khi sử dụng Fake, ta cần phải override chúng.

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

Tiếp theo, ta chỉ cần bỏ đi các dòng code sử dụng stubbing là xong.

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

Tuy nhiên, khi chạy đến case này thì fail

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

Lý do là mình đã override hàm clear và return Future.value(true) nhưng ở đây chúng ta đang cần giả lập nó return Future.value(false). Như vậy, chúng ta không thể sử dụng class FakeSharedPreferences để test case này được mà phải tạo class mới để override hàm clear 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);
}
}

Bây giờ, đối với case bị lỗi trên, ta sẽ sử dụng class SecondFakeSharedPreferences

final fakeSharedPreferences = SecondFakeSharedPreferences();

Full source code

Có thể dễ dàng thấy, nếu chúng ta sử dụng Mock thì chúng ta chỉ cần tạo 1 class Mock duy nhất, sau đó chúng ta có thể sử dụng các stub method để một method có thể trả về các kết quả khác nhau tuỳ vào test case. Tuy nhiên, khi chúng ta sử dụng Fake, chúng ta có thể phải cần tạo nhiều class Fake để làm được điều đó. Đó cũng là nhược điểm khi sử dụng Fake. Vậy ưu điểm của Fake là gì?

Để thấy được ưu điểm của Fake, ta sẽ đến với một ví dụ khác. Giả sử chúng ta có class JobViewModelJobRepositoryJobData như sau:

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

Đây là 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;
}
}
}

Bây giờ, chúng ta sẽ viết test cho class JobViewModel.

Đầu tiên, chúng ta cần tạo ra class FakeJobRepository. Chúng ta sẽ tạo 1 biến jobDataInDb có kiểu List<JobData> để giả lập data thật trong Isar Database. Sau đó, chúng ta sẽ phải override lại cả 4 method của JobRepository.

class FakeJobRepository extends Fake implements JobRepository {
// Giả sử ban đầu trong DB đã có 3 job
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;
}
}

Bây giờ, ta sẽ test hàm addJob trong 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',
});
});
});

Xem thêm các test case khác tại đây.

Như chúng ta thấy, chúng ta không chỉ test đơn lẻ từng hàm getAllJobsaddJob mà còn có thể test được case khi các hàm đó phối hợp với nhau. Điều này giúp cho việc test giống với khi chạy trên môi trường thật hơn.

Nếu sử dụng Mock và Stub để test hàm addJob thì code sẽ như thế này:

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

Nếu test thế này sẽ không thể biết được hàm addJob chạy đúng hay sai. Thật vô nghĩa. Hoặc có bạn sẽ code thế này:

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

Rõ ràng khi chúng ta đã stub để kết quả trả về 4 JobData thì chắc chắn kết quả trong hàm expect sẽ là 4 JobData rồi. Như vậy thì chúng ta cũng không thể biết được hàm addJob chạy đúng hay sai.

Tóm lại, trong trường hợp này, nếu dùng Fake thì việc test sẽ hiệu quả hơn dùng Mock. Hơn nữa, chúng ta còn có thể thêm delay vào các hàm override ở trên để giúp cho bài test sát với môi trường thật hơn nữa.

@override
Future<List<JobData>> getAllJobs() async {
await Future<dynamic>.delayed(const Duration(milliseconds: 1000));
return jobDataInDb;
}

Việc thêm delay còn giúp ta test được trạng thái loading khi test UI.

Chính vì những ưu điểm trên mà kỹ thuật Faking thường được sử dụng để fake các class Repository.

Kết luận

Qua bài viết này, chúng ta đã biết thêm một kỹ thuật mới trong testing là Faking. Nhờ đó, chúng ta có thêm nhiều lựa chọn để có thể viết được những bài test khó nhất một cách hiệu quả nhất.

--

--

NALSengineering

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