Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga. После изучения техник написания Unit-тестов в прошлых частях пришло время перейти к изучению моментов, когда мы не сможем написать тесты. Это означает, что где-то допущены ошибки при написании кода, что усложняет автоматическое тестирование.
Мы объединили 2 статьи (1, 2), чтобы сразу рассказать о всех часто встречаемым ошибкам при написании кода. Поехали!
Hidden text
Пс: новые выпуски в нашем телеграм-канале Flutter. Много. Подписывайся, чтобы не пропустить.

Ошибка 1: Не использовать Dependency Injection (DI)
Без использования DI нельзя использовать Mocking и Stubbing для тестирования разнообразных сценариев.
Для понимания напишем 2 примера: один с DI, а другой без него.
Например, есть класс Storage, как зависимость класса Repository.
class Storage { String getAccessToken() { return 'token'; } }
Автор оригинала под DI подразумевает не сам DI, а правильное написание, чтобы его возможно было использовать.
Теперь напишем код класса Repository с использованием DI.
class Repository { final Storage storage; Repository({required this.storage}); bool get isLoggedIn => storage.getAccessToken().isNotEmpty; }
Без DI класс Repository будет выглядеть так:
class Repository { final storage = Storage(); bool get isLoggedIn => storage.getAccessToken().isNotEmpty; }
Теперь напишем тесты к 2 классам Repository.
Когда используем DI можно создать класс MockStorage.
class MockStorage extends Mock implements Storage {} void main() { late MockStorage mockStorage; late Repository repository; setUp(() { mockStorage = MockStorage(); repository = Repository(storage: mockStorage); }); }
Далее можно использовать Stubbing для имитации функции getAccessToken, чтобы она возвращала empty или non-empty. Таким образом, получается 2 различных тестовых сценария:
test('should return true when the access token is not empty', () { // Arrange when(() => mockStorage.getAccessToken()).thenReturn('access_token'); // Act bool isLoggedIn = repository.isLoggedIn; // Assert expect(isLoggedIn, true); }); test('should return false when the access token is empty', () { // Arrange when(() => mockStorage.getAccessToken()).thenReturn(''); // Act bool isLoggedIn = repository.isLoggedIn; // Assert expect(isLoggedIn, false); });
Если не использовать DI, нельзя подменить класс Storage и имитировать функцию getAccessToken, поэтому будет только один тестовый сценарий.
void main() { late Repository repository; setUp(() { repository = Repository(); }); test('should return true when the access token is not empty', () { // Act bool isLoggedIn = repository.isLoggedIn; // Assert expect(isLoggedIn, true); }); }
Подытоживая, использование DI помогает проверять больше тестовых сценариев.
Ошибка 2: Использовать верхнеуровневые функции и переменные внутри метода, который тестируется
Предположим, в приложении вызываются API из 3 разных серверов: Firebase, Facebook и приватный сервер. Зачастую создаются глобальные переменные для использования в классах Repository. Примерно вот так:
final firebaseApiClient = Dio(BaseOptions(baseUrl: 'https://firebase.google.com')); final appServerApiClient = Dio(BaseOptions(baseUrl: 'https://nals.vn'));
Эти переменные переиспользуются в множестве функций класса Repository.
class Repository { Future<String> getMyJob() async { final response = await appServerApiClient.request('/me/job'); return response.toString(); } Future<String> getAllJobs() async { final response = await appServerApiClient.request('/jobs'); return response.toString(); } }
Если код был написан таким образом, то невозможно протестировать функции getMyJob и getAllJobs.
test('getMyJob should return what the API returns', () async { final repository = Repository(); final jobs = await repository.getMyJob(); expect(jobs, 'IT'); // Откуда я знаю, что API вернет «IT»? });
Repository зависит от глобальной переменной appServerApiClient, и глобальные переменные невозможно подменить и имитировать результат, который возвращает API. Поэтому, нельзя узнать, что за API вернет, чтобы передать это в функцию expect.
Более того, когда не заменяется appServerApiClient при запуске теста, он будет делать реальный запрос к API, что приведет к риску падения теста из-за ошибок сервера с кодами 4xx и 5xx.

Теперь отрефакторим этот код, чтобы стало возможным написать тесты.
Вместо создания 3 глобальных переменных, нужно создать 3 класса.
class AppServerApiClient { final Dio dio; AppServerApiClient() : dio = Dio(BaseOptions(baseUrl: 'https://nals.vn')); Future<Response> request(String path) async { return dio.request(path); } } class FirebaseApiClient { final Dio dio; FirebaseApiClient() : dio = Dio(BaseOptions(baseUrl: 'https://firebase.google.com')); Future<Response> request(String path) async { return dio.request(path); } } class FacebookApiClient { ... }
Чтобы избежать дублирования кода, создаем класс BaseApiClient.
class BaseApiClient { final String baseUrl; final Dio dio; BaseApiClient(this.baseUrl) : dio = Dio(BaseOptions(baseUrl: baseUrl)); Future<Response> request(String path) async { return dio.request(path); } } class AppServerApiClient extends BaseApiClient { AppServerApiClient() : super('https://nals.vn'); } class FirebaseApiClient extends BaseApiClient { FirebaseApiClient() : super('https://firebase.google.com'); } class FacebookApiClient extends BaseApiClient { FacebookApiClient() : super('https://facebook.com'); }
Далее внедряем их в Repository.
class Repository { final AppServerApiClient appServerApiClient; final FirebaseApiClient firebaseApiClient; final FacebookApiClient facebookApiClient; Repository({ required this.appServerApiClient, required this.firebaseApiClient, required this.facebookApiClient, }); ... }
Теперь можно создать Mock-объект для API и использовать технику Stubbing.
class MockAppServerApiClient extends Mock implements AppServerApiClient {} class MockFirebaseApiClient extends Mock implements FirebaseApiClient {} class MockFacebookApiClient extends Mock implements FacebookApiClient {} ... test('getMyJob should return ', () async { // Stub when(() => mockAppServerApiClient.request('/me/job')).thenAnswer( (_) async => Response( requestOptions: RequestOptions(path: '/me/job'), data: 'IT', ), ); // Act final jobs = await repository.getMyJob(); // Assert expect(jobs, 'IT'); });
Ошибка 3: Вызывать функцию плагина, которая использует нативный код, внутри тестируемой функции
Часто используются функции напрямую из таких плагинов, как FirebaseAnalytics, FirebaseCrashlytics, FirebaseFirestore и других, внутри функций классов Repository:
class Repository { Future<String> getMyJob() async { final response = await FirebaseFirestore.instance.collection('job').doc('me').get(); return response.data()?['data'] ?? ''; } }
Такой код также нарушает и ошибку 2, так как невозможно использовать Mocking для класса FirebaseFirestore, что приводит к вызову реальной функции и падению теста. Также, если функция плагина, которая использует нативный код, была запущена в тестовом окружении, получится ошибка:
MissingPluginException(No implementation found for method someMethodName on channel some_channel_name)
Для этого случая понадобится создать класс, в который нужно обернуть вызов функции плагина. В это же время, будет очень тяжело написать тесты для этого класса, поэтому попробуем сделать его функции максимально простыми и логичными.
class FirebaseFirestoreService { Future<Map<String, dynamic>?> getMyJob() async { final response = await FirebaseFirestore.instance.collection('job').doc('me').get(); return response.data(); } }
Остальная логика будет написана в классе Repository.
class Repository { final FirebaseFirestoreService firebaseFirestoreService; Repository({required this.firebaseFirestoreService}); Future<String> getMyJob() async { final response = await firebaseFirestoreService.getMyJob(); return response?['data'] ?? ''; } }
Теперь можно написать тесты для класса Repository.
Обратите внимание, что плагины, которые используют только код на Dart, могут работать нормально в Unit-тестах. Помимо вышеуказанного исправления, можно обратиться к другим способам здесь.
Ошибка 4: Не отделять логику от UI
Добавление логики в виджеты, которая не может быть протестирована как UI, сделает эту логику сложной для тестирования. Например:
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'); } } }
Если код такой, то невозможно написать тесты на функцию login, так как класс LoginButton — это виджет и не может быть проинициализирован в тестовом окружении.
Нужно создать еще один класс для логики и отделить ее от UI.
class LoginViewModel { bool login(String email, String password) { if (email.isEmpty || password.isEmpty) { return false; } else { return true; } } }
В коде выше нельзя проверить был ли показан SnackBar или было ли переключение на Home screen. Для тестирования строк кода Flutter плагинов, таких как Navigator и ScaffoldMessenger, нужно создать класс-оболочку для таких функций:
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); } }
Теперь, нужно изменить функцию 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'); } }
Сейчас можно написать тесты.
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); });
В этом коде не использовались никакие популярные решения для управления состоянием, такие как Riverpod, BLoC и т. п. Также не использовались шаблоны проектирования MVC, MVP или MVVM, поэтому код получился не очень чистым и с анти-паттернами.
На самом деле, если используются пакеты для управления состоянием Riverpod, BLoC или шаблоны типа MVC, MVP, MVVM, то это поможет отделить логику от UI и повысить качество кода, чтобы написать на него тесты.
Правильное использование архитектурных подходов облегчает тестирование. Поэтому используйте SOLID и будет вам счастье!
Ошибка 5: Использовать DateTime.now()
Предположим, что нужно протестировать функцию isNewJob.
class Job { final DateTime postedAt; Job({required this.postedAt}); bool get isNewJob => DateTime.now().difference(postedAt).inDays <= 7; }
28 января 2024 года — день, когда автор писал статью, поэтому тест написан со сценарием ровно на неделю раньше — 21 января 2024 года.
Даты, используемые автором оригинала, сохранены.
test('isNewJob returns true if job is posted within 7 days', () { final job = Job(postedAt: DateTime(2024, 1, 21)); expect(job.isNewJob, true); });
Однако, если запустить этот тест завтра, то он упадет, так как пройдет уже 8 дней с postedAt.
Для того, чтобы это исправить, нужно добавить пакет clock и заменить DateTime.now() на clock.now().
bool get isNewJob => clock.now().difference(postedAt).inDays <= 7;
В это же время необходимо использовать функцию withClock, чтобы имитировать текущее время.
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); }); });
Как можно увидеть, даже если изменить postedAt на 2023 год, то результат останется верным. Это происходит из-за того, что изменилась имитация текущего времени на использование 2023 года.
Ошибка 6: Написать слишком большую функцию или поделить ее на много слишком маленьких
До этого была написана функция, которая совершала много действий на Splash screen, включая:
-
Получение Remote Config из Firebase.
-
Проверка версии приложения для принудительного обновления.
-
Проверка, что пользователь впервые запустил приложение.
-
Проверка, что необходимо показать пользователю рекомендацию обновиться и важные диалоговые окна.
Вот этот код:
class UseCaseOutput { final Config remoteConfig; // remote config from Firebase final bool needForceUpdate; // need force update or not final bool isFirstLogin; // is this the first time login? final bool recommendUpdateApp; // need to show dialog to recommend update app final bool isShowImportantNotice; // need show dialog with important notice 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'; }
Написать метод с таким большим количеством функционала приведет к тому, что в тестах будет излишнее дублирование кода. Например, нужно проверить только функцию проверки для принудительного обновления, но требуется использовать Mocking и Stubbing для других функций, которые с этим не связаны.
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);
Если необходимо протестировать всего один сценарий, то нужно повторить минимум 3 строчки кода. Тогда как при проверке множества кейсов, количество ненужного кода будет очень большим.
Более того, когда изменится код в функции lastRecommendTime и нужно будет переписать тест, то тест кейсы для принудительного обновления тоже будут затронуты. Тогда придется фиксить множество тестов, не связанных с функцией lastRecommendTime.
С другой стороны, если поделить функцию на слишком много мелких, это тоже плохо. Потому что тогда придется также писать множество тест кейсов и что более важно — множество маленьких тестов, что не поможет в обеспечении качества.
Как уже было упомянуто в первой части, Unit-тесты фокусируются только на тестировании отдельной функции. И можно удостовериться только, что каждая функция запускается корректно, но без гарантии, что вместе они будут работать как надо.
В общем, если написать большую функцию, которая объединяет много функционала или разбить функцию на слишком мелкие, то это не скажется хорошо на написании тестов в дальнейшем.
Заключение
Выше были представлены часто встречаемые ошибки, которые обнаруживаются во время написания кода и делают написание тестов сложнее. Надеемся, что эта статья даст вам больше знаний и опыта, чтобы лучше проектировать код для упрощения написания тестов и покрытия большего количества сценариев.
В следующей статье поговорим про best practices при написании тестов.
Подписывайся на телеграм-канал Flutter. Много, чтобы не пропустить новый выпуск!
ссылка на оригинал статьи https://habr.com/ru/articles/835506/
Добавить комментарий