Bách khoa toàn thư về Test trong Flutter. Tập 8: 3 sai lầm khi code mà cả Senior cũng mắc phải.

NALSengineering
6 min readJan 29, 2024
Source: Unsplash

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

Mở đầu

Trong bài trước, mình đã giới thiệu một số lỗi điển hình khi viết code khiến cho việc viết test trở nên khó khăn. Trong bài viết này, mình sẽ tiếp tục giới thiệu những sai lầm nghiêm trọng khác mà kể cả những người code lâu năm kinh nghiệm cũng có thể mắc phải.

Sai lầm 4: Xử lý logic và UI vào cùng một class hoặc một hàm.

Việc code chung logic vào các thành phần không thể viết unit test như UI sẽ khiến cho đoạn logic đó trở nên khó viết test. Ví dụ,

class LoginButton extends StatelessWidget {
const LoginButton({super.key, required this.email, required this.password});

final String email;
final String password;

@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
login(context, email, password);
},
child: const Text('Login'),
);
}

void login(BuildContext context, String email, String password) {
if (email.isEmpty || password.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Email and password are required'),
),
);
} else {
Navigator.of(context).pushNamed('home');
}
}
}

Nếu code như vậy thì ta sẽ không thể viết test cho hàm login được vì class LoginButton là một widget nên không thể khởi tạo trong môi trường test.

Ta cần phải tạo 1 class khác để chứa phần logic và tách biệt nó khỏi UI.

class LoginViewModel {
bool login(String email, String password) {
if (email.isEmpty || password.isEmpty) {
return false;
} else {
return true;
}
}
}

Nếu code như trên, thì chúng ta cũng không thể xác minh được rằng SnackBar có show hay không hoặc có di chuyển đến màn hình Home hay không. Để test được những dòng code của Flutter plugin như NavigatorScaffoldMessenger, ta cần tạo class mới để wrap các hàm đó:

class AppNavigator {
final BuildContext context;

AppNavigator(this.context);

void showSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
),
);
}

void pushNamed(String name) {
Navigator.of(context).pushNamed(name);
}
}

Bây giờ, điều chỉnh lại hàm login


void login(AppNavigator navigator, String email, String password) {
if (email.isEmpty || password.isEmpty) {
navigator.showSnackBar('Email and password are required');
} else {
navigator.pushNamed('home');
}
}

Như vậy là chúng ta đã có thể viết test được rồi

class MockAppNavigator extends Mock implements AppNavigator {}

...

test('navigator.push should be called once when the email and password are not empty', () {
// Arrange
String email = 'ntminh@gmail.com';
String password = '123';

// Act
loginViewModel.login(mockAppNavigator, email, password);

// Assert
verifyNever(() => mockAppNavigator.showSnackBar(any()));
verify(() => mockAppNavigator.pushNamed('home')).called(1);
});

Full source code

Chú thích thêm là đoạn code trên, mình không sử dụng bất kỳ package state management phổ biến như Riverpod, Bloc,… cũng không sử dụng bất kỳ pattern nào như MVC, MVP hay MVVM nên code sẽ không được clean và anti-pattern. Trong thực tế, việc sử dụng các package state management như Riverpod, Bloc hoặc các pattern như MVC, MVP, MVVM cũng sẽ giúp chúng ta tách được code logic ra khỏi UI và tăng khả năng viết test.

Sai lầm 5: Sử dụngDateTime.now()

Giả sử chúng ta cần test hàm isNewJob như sau

class Job {
final DateTime postedAt;

Job({required this.postedAt});

bool get isNewJob => DateTime.now().difference(postedAt).inDays <= 7;
}

Hôm nay, ngày mình viết bài này là 28/01/2024 nên mình sẽ viết test với case cách đây đúng 7 ngày là 21/01/2024.

test('isNewJob returns true if job is posted within 7 days', () {
final job = Job(postedAt: DateTime(2024, 1, 21));

expect(job.isNewJob, true);
});

Tuy nhiên, khi sang ngày mai, chúng ta chạy lại test này thì nó sẽ fail bởi vì đã 8 ngày trôi qua kể từ postedAt.

Để fix được lỗi này, ta cần phải thêm package clock. Sau đó, ta phải thay DateTime.now() bởi clock.now()

bool get isNewJob => clock.now().difference(postedAt).inDays <= 7;

Khi đó, mình dùng hàm withClock để giả lập thời gian hiện tại

test('isNewJob returns true if job is posted within 7 days', () {
final job = Job(postedAt: DateTime(2023, 1, 10));

withClock(Clock.fixed(DateTime(2023, 1, 17)), () {
expect(job.isNewJob, true);
});
});

Full source code

Như các bạn có thể thấy, ngay cả khi mình điều chỉnh postedAt về năm 2023 thì kết quả vẫn có thể trả về true. Đó là nhờ mình đã giả lập để thời gian hiện tại là năm 2023.

Sai lầm 6: Code một hàm quá lớn hoặc tách ra nhiều hàm quá nhỏ

Trước đây mình từng code 1 hàm mà làm rất nhiều chức năng ở màn hình Splash bao gồm:

  1. Get remote config từ Firebase về
  2. Kiểm tra version để force update app
  3. Kiểm tra đây có phải là lần login đầu tiên không
  4. Kiểm tra xem có cần thiết show các dialog recommend update app và important notice không.

Code của nó như sau:

class UseCaseOutput {
final Config remoteConfig; // remote config từ Firebase
final bool needForceUpdate; // cần force update hay không
final bool isFirstLogin; // có phải login lần đầu không
final bool recommendUpdateApp; // cần show dialog recommend update app không
final bool isShowImportantNotice; // cần show dialog important notice không

const UseCaseOutput({
required this.remoteConfig,
required this.needForceUpdate,
required this.isFirstLogin,
required this.recommendUpdateApp,
required this.isShowImportantNotice,
});
}
class FetchRemoteConfigUseCase {
const FetchRemoteConfigUseCase(this.repository);

final Repository repository;

Future<UseCaseOutput> execute() async {
final remoteConfig = await repository.fetchRemoteConfig();
final currentAppVersion = _getCurrentAppVersion();
var matchedVersion = _checkForceUpdate(remoteConfig.versionList, currentAppVersion);

final lastRecommendTime = DateTime.tryParse(repository.showRecommendUpdateVersionTime);
final lastShowImportantNotice = DateTime.tryParse(repository.showImportantNoticeTime);


return UseCaseOutput(
remoteConfig: matchedVersion?.config ?? remoteConfig.defaultConfig,
needForceUpdate: matchedVersion == null,
isFirstLogin: repository.isFirstLogin,
recommendUpdateApp: matchedVersion?.config != null &&
matchedVersion!.config.recommendUpdateVersion.isRecommendUpdate(lastRecommendTime),
isShowImportantNotice: matchedVersion?.config != null &&
matchedVersion!.config.importantNotice.isShowNotice(lastShowImportantNotice),
);
}

Version _getCurrentAppVersion() {
final versionName = RegExp(r'\d+')
.allMatches(repository.currentAppVersion)
.map((e) => int.tryParse(e.group(0) ?? '0'));

return Version(
major: versionName.elementAtOrNull(0) ?? 0,
minor: versionName.elementAtOrNull(1) ?? 0,
revision: versionName.elementAtOrNull(2) ?? 0,
availableFrom: DateTime.now(),
availableTo: DateTime.now(),
);
}

Version? _checkForceUpdate(List<Version> remoteConfigVersions, Version currentAppVersion) {
Version? currentConfig;
for (final version in remoteConfigVersions.sortedDescending()) {
if (version.isEqualWith(currentAppVersion)) {
if (version.isAvailable) {
currentConfig = version;
}
break;
}

if (currentAppVersion.isGreaterThan(version) && version.isAvailable) {
if (currentConfig == null || version.isGreaterThan(currentConfig)) {
currentConfig = version;
}
}
}

return currentConfig;
}
}
class Repository {
Future<RemoteConfig> fetchRemoteConfig() async => const RemoteConfig();

String get lastRecommendTime => '';

String get lastShowImportantNotice => '';

bool get isFirstLogin => false;

String get currentAppVersion => '1.1.0';
}

Full source code

Việc code một hàm nhiều chức năng như vậy sẽ dẫn đến việc test bị lặp code nhiều một cách không cần thiết. Ví dụ, ta chỉ muốn test chức năng check 2 version để force update nhưng ta phải mock và stub cả những hàm không liên quan:

when(() => _appRepository.fetchRemoteConfig()).thenAnswer((_) => Future.value(remoteConfig));
when(() => _appRepository.currentAppVersion).thenReturn('1.1.0');

// không liên quan đến chức năng force update
when(() => _appRepository.lastRecommendTime).thenReturn('');
when(() => _appRepository.lastShowImportantNotice).thenReturn('');
when(() => _userRepository.isFirstLogin).thenReturn(true);

Mới chỉ test 1 case mà đã lặp ít nhất 3 dòng code rồi thì khi test nhiều case thì số lượng code bị thừa sẽ rất nhiều. Hơn nữa, khi chúng ta sửa code ở hàm lastRecommendTime và cần sửa lại test thì các test case của tính năng force update cũng bị ảnh hưởng và buộc chúng ta phải sửa hàng loạt test case không liên quan đến hàm lastRecommendTime.

Ngược lại, nếu chúng ta chia một hàm thành quá nhiều hàm nhỏ thì cũng không tốt. Vì khi đó, chúng ta cũng sẽ cần viết rất nhiều test case và quan trọng hơn là nhiều bài test nhỏ như vậy sẽ không giúp chúng ta đảm bảo được chất lượng. Như mình đã nói ở tập 1, Unit Test chỉ tập trung test từng function một cách độc lập, vì vậy chúng ta chỉ có thể đảm bảo từng function chạy đúng nhưng không thể đảm bảo khi các function phối hợp với nhau nó cũng đúng như vậy.

Tóm lại, việc viết một hàm lớn gộp nhiều chức năng hay việc tách một hàm thành nhiều hàm nhỏ đều không tốt cho việc viết test sau này.

Kết luận

Trên đây là những lỗi thường gặp phải trong quá trình code khiến cho việc viết test trở nên khó khăn. Qua bài viết này, chúng ta đã trang bị thêm những kiến thức, kinh nghiệm và trải nghiệm để có thể thiết kế code một cách đúng đắn để giúp cho việc viết test được dễ dàng hơn và cover được nhiều case hơn. Trong bài viết tiếp theo, mình sẽ giới thiệu các best practices khi viết test.

--

--

NALSengineering

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