Bách khoa toàn thư về Test trong Flutter. Tập 4: Unit Test nâng cao.
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 mockSharedPreferences
và loginViewModel
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 mockSharedPreferences
và loginViewModel
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);
});
...
}
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àmexpect
ở chỗexpect
được sử dụng để kiểm tra một giá trị đồng bộ, cònexpectLater
đượ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ệnhexpectLater
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ùngawait
trước hàmexpectLater()
, nếu không, test sẽ fail. - Hàm
emitsInOrder
: là một matcher được sử dụng để kiểm tra xem mộtStream
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 matcheremitsInAnyOrder
.
Để đả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([[]]));
});
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.