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