Bách khoa toàn thư về Test trong Flutter. Tập 4: Unit Test nâng cao.

NALSengineering
7 min readJan 23, 2024
Source: Unsplash

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

Đặt vấn đề

Trong bài trước, chúng ta đã biết cách sử dụng kỹ thuật Mocking và Stubbing để test những class bị phụ thuộc vào những class khác. Trong bài này, chúng ta sẽ cùng nhau nâng độ khó của class LoginViewModel lên thêm một chút nữa. Cụ thể là chúng ta sẽ tạo ra biến _cache để cache các kết quả get từ SharedPreferences và khi gọi hàm login thì chúng ta sẽ ưu tiên lấy từ cache ra để so sánh trước.

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

Đoạn code trên có 1 vấn đề là biến _cache là private với giá trị khởi tạo là rỗng. Hơn nữa, chúng ta không thể mock hay stub trên chính object cần test là loginViewModel được. Như vậy, chúng ta chỉ có đúng 1 kịch bản test duy nhất là khi _cache là rỗng. Làm thế nào để chúng ta put thêm value vào _cache để test nhiều kịch bản khác nhau mà vẫn giữ nó là private? Đó là lúc chúng ta cần đến annotation @visibleForTesting

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

Khi một hàm được đánh dấu bởi annotation này thì chúng ta muốn ám chỉ rằng hàm đó chỉ được sử dụng trong các file test và sử dụng trong chính file chứa hàm đó. Như vậy, về bản chất thì hàm đó vẫn là private.

Bây giờ, chúng ta đã có thể viết test cho 2 kịch bản test như sau:

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

Như vậy, ta đã test được các case liên quan đến _cache.

Tiếp theo, ta thử refactor một chút để tránh duplicate code bằng cách đưa 2 dòng khởi tạo mockSharedPreferencesloginViewModel ra hàm main để tái sử dụng cho nhiều hàm test

Khi chạy lại test, ta sẽ thấy test case thứ 2 bị lỗi.

Trước khi sử dụng chung các object mockSharedPreferencesloginViewModel cho các test case thì Expected = Actual = false nhưng sau khi sử dụng chung thì Actual đổi lại thành true. Tại sao? Khi sử dụng chung object loginViewModel thì chúng ta cũng đang sử dụng chung biến _cache mà ở test case thứ 1 chúng ta có put 1 value vào _cache loginViewModel.putToCache(email, ‘abc’); nên khi chạy sang test case thứ 2 thì _cache đã có có sẵn value là ‘abc’ rồi do đó _cache có contains password input và return true.

Để fix được lỗi ta phải đảm bảo loginViewModel phải được tạo mới mỗi khi chạy một test case mới. Để làm được điều đó, ta cần sử dụng hàm setUp.

void main() {
late MockSharedPreferences mockSharedPreferences;
late LoginViewModel loginViewModel;

setUp(() {
mockSharedPreferences = MockSharedPreferences();
loginViewModel = LoginViewModel(sharedPreferences: mockSharedPreferences);
});

...
}

Full source code

Bây giờ, hãy thử chạy lại test, ta sẽ thấy tất cả test đã pass.

Hàm setUp, tearDown, setUpAll, tearDownAll

  • Hàm setUp

Hàm setUp được chạy trước khi chạy mỗi test case, vì vậy nó thường được sử dụng để khởi tạo đối tượng test, cấu hình các giá trị ban đầu. Trong ví dụ trên, thứ tự thực thi của các hàm như sau:

setUp (khởi tạo) -> test case 1 -> setUp (khởi tạo lại) -> test case 2

Nhờ vậy, loginViewModel được khởi tạo lại trước khi chạy test case 2 nên chúng ta fix được lỗi.

  • Hàm tearDown

Hàm tearDown được chạy sau khi mỗi test case chạy xong, vì vậy nó thường được sử dụng để dọn dẹp, giải phóng bộ nhớ, close bloc,…

  • Hàm setUpAll

setUpAll là một hàm chỉ chạy một lần trước khi chạy tất cả các test, vì vậy nó thường được sử dụng để kết nối đến database và sử dụng chung database đó cho tất cả các test.

setUpAll(() async {
await Isar.initializeIsarCore(download: true);
isar = await Isar.open([JobSchema], directory: '');
});
  • Hàm tearDownAll

tearDownAll là một hàm chỉ chạy một lần sau khi chạy tất cả các test đã chạy xong, vì vậy nó thường được sử dụng để đóng tất cả các kết nối đến database.

tearDownAll(() async {
await isar.close();
});

Để hiểu rõ cách ứng dụng 4 hàm này, mình sẽ tạo ra một example mới. Đặc biệt hơn trong ví dụ lần này, chúng ta sẽ thử test các Stream.

Testing Streams

Giả sử app của chúng ta sử dụng Isar database và có 1 table là JobData.

import 'package:isar/isar.dart';

part 'job_data.g.dart';

@collection
class JobData {
Id id = Isar.autoIncrement;
late String title;
}

Chúng ta có 1 class HomeBloc, trong class này chúng ta sẽ lắng nghe data trả về từ 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;
}
}

Bây giờ, ta sẽ tạo 1 file test để test getter data của HomeBloc.

Tuỳ vào đặc điểm của Isar và Bloc mà chúng ta sẽ khởi tạo chúng ở hàm setUp hoặc setUpAll. Thông thường, nếu chúng ta khởi tạo object ở hàm setUp thì sẽ dọn dẹp object đó ở hàm tearDown, nếu chúng ta khởi tạo ở hàm setUpAll thì sẽ dọn dẹp ở hàm tearDownAll.

void main() {
late Isar isar;
late HomeBloc homeBloc;

setUp(() async {
// vì chúng ta không khởi tạo lại isar nên chúng ta cần clear nó để
// đảm bảo mỗi test case đều khởi chạy với DB rỗng
await isar.writeTxn(() async => isar.clear());
homeBloc = HomeBloc(isar: isar);
});

tearDown(() {
// hàm tearDown phù hợp để dọn dẹp những gì khai báo trong hàm setUp
homeBloc.close();
});

setUpAll(() async {
// chúng ta chỉ cần tạo ra 1 DB duy nhất sử dụng chung cho nhiều test case
// vì vậy chúng ta khởi tạo DB ở hàm setUpAll
await Isar.initializeIsarCore(download: true);
isar = await Isar.open(
[
JobDataSchema,
],
directory: '',
);
});

tearDownAll(() {
// hàm tearDown phù hợp để dọn dẹp những gì khai báo trong hàm setUpAll
isar.close();
});
}

Tiếp theo, ta sẽ viết test cho 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');
});
});

Có 2 hàm lạ ở đây:

  • Hàm expectLater: Hàm này khác với hàm expect ở chỗ expect được sử dụng để kiểm tra một giá trị đồng bộ, còn expectLater được sử dụng để kiểm tra giá trị bất đồng bộ như Stream. Khi chúng ta muốn test stream, thì lệnh expectLater phải được đặt trước khi stream emit event. Bằng cách này, chúng ta có thể quan sát các giá trị khi stream phát ra chúng. Trong trường hợp như vậy, không nên dùng await trước hàm expectLater(), nếu không, test sẽ fail.
  • Hàm emitsInOrder: là một matcher được sử dụng để kiểm tra xem một Stream có phát ra các event theo một thứ tự cụ thể hay không. Ngoài ra, nếu chúng ta muốn kiểm tra các event nhưng không cần phải theo thứ tự thì sử dụng matcher emitsInAnyOrder.

Để đảo bảo hàm các hàm setUp, tearDown ở trên chạy đúng, chúng ta sẽ thử tạo ra 1 test case thứ hai:

test('data should emit nothing when Isar does not emit any events', () async {
expectLater(homeBloc.data, emitsInOrder([[]]));
});

Full source code

Sau khi chạy test, ta sẽ thấy all tests passed.

Hàm resetMocktailState

Hàm này được dùng để reset các mock object. Đối với lỗi ở trên, thay vì khởi tạo lại mock object trong hàm setUp, ta có thể gọi hàm này trong hàm setUp hoặc tearDown thì cũng có thể fix được lỗi.

tearDown(() {
resetMocktailState();
});

Kết luận

Trong bài hôm nay, chúng ta đã viết test cho những case nâng cao hơn. Trong bài tiếp theo, chúng ta sẽ tiếp tục tìm hiểu sâu hơn về thư viện mocktail để viết test cho những case phức tạp hơn nữa.

--

--

NALSengineering

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