Полный гайд по тестированию на Flutter. Часть 7: Ошибки, которые усложняют написание тестов

от автора

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, включая:

  1. Получение Remote Config из Firebase.

  2. Проверка версии приложения для принудительного обновления.

  3. Проверка, что пользователь впервые запустил приложение.

  4. Проверка, что необходимо показать пользователю рекомендацию обновиться и важные диалоговые окна.

Вот этот код:

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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *