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. Много, чтобы не пропустить новый выпуск!
 
          