Написание тестов во Flutter {часть 1} : Как писать модульные тесты во Flutter

Любая часть программного обеспечения должна работать надежно. Надежность возникает благодаря детерминированному результату. Один из способов привнести надежность в разработку программного обеспечения – это написание тестов.

Тесты не только помогают отлавливать ошибки на ранних стадиях разработки, но и позволяют убедиться, что новый код не нарушает старую функциональность. Это заставляет вас писать модульный код.

В этом руководстве вы научитесь писать** модульные тесты для приложения flutter.**.

Стартовый проект

Клонируйте стартовый проект отсюда. Репо основано на шаблоне Wednesday Flutter Template, но для этого урока оно урезано.

После клонирования проекта откройте его в VS Code или Android studio и проверьте ветку unit-test-start.

Вы увидите следующую структуру каталогов.

Соберите и запустите приложение. Вы должны увидеть приложение, как показано ниже, запущенное на вашем симуляторе/эмуляторе.

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

Что такое модульные тесты?

Юнит-тест – это тест, который проверяет поведение небольшого и изолированного участка кода. Небольшой и _изолированный_ – два важных параметра, которые необходимо учитывать при написании модульного теста.

Юнит-тест будет нацелен на одну функцию. Любые входные данные для этой функции будут предоставлены вами во время написания теста. Любые зависимости, например, другие функции, которые вызываются изнутри, должны быть имитированы.

Юнит-тесты выполняются быстро и не требуют длительной настройки.

Забавный факт: в производственном приложении количество модульных тестов будет намного больше, чем любых других типов тестов.

Что такое тестируемый код?
Прежде чем приступать к написанию тестовых примеров, необходимо убедиться, что код, который вы пишете, пригоден для тестирования.

Рассмотрим следующий пример. У нас есть класс Calculator с функцией add. Класс Calculator внутренне создает объект CalculatorAPIService для выполнения вычислений.

Чтобы протестировать функцию add, необходимо знать, как работает класс CalculatorAPIService. Этот архитектурный паттерн плох по ряду причин:

  • Любой сбой в работе CalculatorAPIService приведет к неудаче вашего теста.
  • У CalculatorAPIService может быть больше собственных зависимостей.
  • Тестирование состояний отказа может зависеть от CalculatorAPIService.
  • Тестирование состояний ошибки станет затруднительным.

Для решения этой проблемы можно использовать инъекцию зависимостей или инверсию контроля. Вместо того чтобы позволить классу Calculator создавать свои собственные зависимости, мы предоставляем их классу через его конструктор. Таким образом, вы можете контролировать поведение зависимостей.

Примечание: Рассмотрение концепции Dependency Injection выходит за рамки данного руководства. В этом репозитории мы используем get_it в качестве решения Dependency Injection, и вы можете прочитать больше о нем здесь.

Зачем нужен мокинг?

Подражание означает замену зависимостей тестируемого фрагмента кода на поддельную или дублирующую версию, которую вы контролируете.

Макетирование зависимостей тестируемого объекта позволит вам добиться предсказуемого поведения при каждом запуске теста. С помощью макета вы можете заставить функцию вернуть определенное значение или выбросить ошибку, когда захотите. Макеты также позволяют проверить, была ли определенная функция вызвана определенное количество раз. Все это вы увидите на практике, когда будете писать реальные тесты в следующих разделах.

С помощью инъекции зависимостей вы можете передавать моделируемые версии зависимостей в тестируемый класс. В этом руководстве вы будете использовать mocktail для имитации необходимых зависимостей. Подробнее о mocktail вы можете прочитать здесь.

Анатомия модульного теста

Каждый модульный тест имеет общую структуру. В целом ее можно разделить на 3 этапа: этап предварительного тестирования, этап **тестирования и этап посттестирования.

Предварительный тест: На этом этапе происходит инициализация макетов и тестируемого класса.
Тестирование: Здесь выполняется тест.
Посттест: Здесь происходит сброс значений макета.

Создание файла теста

Во flutter все файлы тестов должны заканчиваться на _test.dart и должны быть помещены в каталог test.

В стартовом проекте вы увидите пустой каталог test. Именно здесь мы будем писать все тесты. Вы будете писать тесты для weather_repository_impl.dart. Он находится по адресу repository/weather.

Хорошей идеей является имитация структуры каталогов lib в папке с тестами, так как это облегчает поиск соответствующих файлов тестов.

  • Поскольку weather_repository_impl.dart **in расположен в **lib/repository/weather, создайте новый файл weather_repository_impl_test.dart в test/repository/weather.

Добавьте метод main. Весь тестовый код должен находиться внутри этого главного метода.

void main() {
  // Test code here!
}
Вход в полноэкранный режим Выйдите из полноэкранного режима

Начальная настройка теста

Чтобы протестировать функцию в weather_repository_impl.dart, нам сначала нужно создать экземпляр хранилища. Функция setUp – идеальное место для этого.

Добавьте функцию setUp в созданный вами тестовый файл и создайте здесь экземпляр WeatherRepositoryImpl.

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_testing/repository/weather/weather_repository_impl.dart';

void main() {
  late WeatherRepositoryImpl weatherRepository;

  setUp(() {
    weatherRepository = WeatherRepositoryImpl();
  });
}
Перейдите в полноэкранный режим Выход из полноэкранного режима

Вы заметите, что WeatherRepositoryImpl требует передачи некоторых других классов в его конструктор. Поскольку вы тестируете только класс WeatherRepositoryImpl, вы не хотите, чтобы другие классы влияли на результат теста. Здесь важную роль играет мокинг. Мы предоставим все зависимости в виде моков, чтобы мы могли контролировать их поведение по мере необходимости.

Откройте pubspec.yaml и добавьте зависимость mocktail в раздел dev_dependencies.

dev_dependencies: mocktail: ^0.3.0 # другие зависимости

Запустите flutter pub get после добавления зависимости.

После добавления зависимости приступим к добавлению макетов. Поскольку макеты повторно используются в нескольких тестовых файлах, лучше извлечь их в отдельную папку. Создайте новый каталог под тестом под названием mocks и добавьте в него файл mocks.dart.

Класс WeatherRepositoryImpl имеет несколько сервисов, несколько мапперов и хранилище в качестве зависимостей.

Примечание:-
-Сервисы – это классы, которые предоставляют доступ к источникам данных.
-Мапперы – это классы, которые преобразуют классы данных сервиса в классы данных домена.

Создайте** макеты** для всех зависимостей. Для создания макета достаточно расширить класс Mock. Здесь мы также реализуем интерфейс, который используется исходным классом, чтобы он был идентифицирован как правильный объект. Добавьте следующее в файл mocks.dart.

import 'package:flutter_testing/repository/date/date_repository.dart';
import 'package:flutter_testing/repository/weather/domain_city_mapper.dart';
import 'package:flutter_testing/repository/weather/local_city_mapper.dart';
import 'package:flutter_testing/repository/weather/local_day_weather_mapper.dart';
import 'package:flutter_testing/repository/weather/local_weather_mapper.dart';
import 'package:flutter_testing/services/weather/local/weather_local_service.dart';
import 'package:flutter_testing/services/weather/remote/weather_remote_service.dart';
import 'package:mocktail/mocktail.dart';

// Service
class MockWeatherLocalService extends Mock implements WeatherLocalService {}

class MockWeatherRemoteService extends Mock implements WeatherRemoteService {}

// Mappers
class MockDomainCityMapper extends Mock implements DomainCityMapper {}

class MockLocalCityMapper extends Mock implements LocalCityMapper {}

class MockLocalWeatherMapper extends Mock implements LocalWeatherMapper {}

class MockLocalDayWeatherMapper extends Mock implements LocalDayWeatherMapper {}

// Repositories
class MockDateRepository extends Mock implements DateRepository {}

Войти в полноэкранный режим Выйти из полноэкранного режима

После объявления макетов можно вернуться к файлу weather_repository_impl_test.dart и использовать макеты в качестве зависимостей для класса WeatherRepositoryImpl, как показано ниже.

import 'package:flutter_testing/repository/date/date_repository.dart';
import 'package:flutter_testing/repository/weather/domain_city_mapper.dart';
import 'package:flutter_testing/repository/weather/local_city_mapper.dart';
import 'package:flutter_testing/repository/weather/local_day_weather_mapper.dart';
import 'package:flutter_testing/repository/weather/local_weather_mapper.dart';
import 'package:flutter_testing/repository/weather/weather_repository.dart';
import 'package:flutter_testing/repository/weather/weather_repository_impl.dart';
import 'package:flutter_testing/services/weather/local/weather_local_service.dart';
import 'package:flutter_testing/services/weather/remote/weather_remote_service.dart';
import '../../mocks/mocks.dart';

void main() {
  late WeatherLocalService weatherLocalService;
  late WeatherRemoteService weatherRemoteService;
  late DomainCityMapper domainCityMapper;
  late LocalCityMapper localCityMapper;
  late LocalWeatherMapper localWeatherMapper;
  late LocalDayWeatherMapper localDayWeatherMapper;
  late DateRepository dateRepository;

  late WeatherRepository weatherRepository;

  setUp(() {
    weatherLocalService = MockWeatherLocalService();
    weatherRemoteService = MockWeatherRemoteService();
    domainCityMapper = MockDomainCityMapper();
    localCityMapper = MockLocalCityMapper();
    localWeatherMapper = MockLocalWeatherMapper();
    localDayWeatherMapper = MockLocalDayWeatherMapper();
    dateRepository = MockDateRepository();

    weatherRepository = WeatherRepositoryImpl(
      weatherLocalService: weatherLocalService,
      weatherRemoteService: weatherRemoteService,
      domainCityMapper: domainCityMapper,
      localCityMapper: localCityMapper,
      localWeatherMapper: localWeatherMapper,
      localDayWeatherMapper: localDayWeatherMapper,
      dateRepository: dateRepository,
    );
  });
}
Вход в полноэкранный режим Выход из полноэкранного режима

Затем добавьте функцию tearDown для сброса состояния.

void main() {
    ...

    tearDown(() {
    resetMocktailState();
  });
}

Вход в полноэкранный режим Выход из полноэкранного режима

Это было много шагов по настройке. К счастью, вам не придется делать это каждый раз, поскольку некоторые из этих настроек (например, макеты) могут быть общими для нескольких тестовых файлов.

Теперь вы готовы написать свой первый тест.

Написание модульного теста

Первым шагом к написанию модульного теста является выбор единицы кода, которую вы хотите протестировать. Для первого теста выберем функцию setCityAsFavorite из самого низа WeatherRepositoryImpl.

@override
  Future setCityAsFavorite(City city) async {
    await weatherLocalService.markCityAsFavorite(
      city: localCityMapper.map(city),
    );
  }

Вход в полноэкранный режим Выход из полноэкранного режима

Выбрав единицу кода, определите, какие шаги в ней выполняются. Это поможет определить, какие части нужно высмеивать, а какие проверять в тесте. Например.

  • setCityAsFavorite принимает город в качестве входных данных.
  • Город преобразуется в LocalCity путем вызова функции localCityMapper.map.
  • markCityAsFavorite вызывается на weatherLocalService с результатом функции map.

Теперь напишем тестовую функцию. Она будет запускаться каждый раз, когда вы вызываете flutter test. Она принимает 2 параметра, имя теста и собственно код теста. Для описания теста вы будете использовать паттерн Given, When, Then. Он разбивает тест на три части:

  • Учитывая некоторый контекст
  • Когда выполняется некоторое действие
  • Затем должен произойти определенный набор действий. Напишите новый тест, как показано ниже. Вы назовете его по шаблону *Given, When, Then*.
test("Given a valid City object, When setCityAsFavorite is called and no error occurs, Then markCityAsFavorite is called with LocalCity object", () {
  // Given

  // When

  // Then
});
Войти в полноэкранный режим Выйти из полноэкранного режима

WeatherLocalService – это объект-макет, вызов любых функций в нем не вернет никаких значений. Вам необходимо указать библиотеке mocking, какое значение должно быть возвращено при вызове определенной функции с определенным набором аргументов. Для этого вы также создадите несколько объектов данных с фиктивными данными. Для текущей тестовой функции нам нужны объекты данных City и LocalCityCompanion.

test("Given a valid City object, When setCityAsFavorite is called and no error occurs, Then markCityAsFavorite is called with LocalCity object", () {
    // Given
    final testCity = City(
      id: 1,
      title: "title",
      locationType: "locationType",
      location: "location",
    );
    const testLocalCity = LocalCityCompanion(
      woeid: Value(1),
      title: Value("title"),
      locationType: Value("locationType"),
      location: Value("location"),
    );

    // When

    // Then
  });
Вход в полноэкранный режим Выход из полноэкранного режима

Библиотеке mocking необходимо сообщить, что при вызове localCityMapper.map с testCity она должна вернуть testLocalCity. Для этого используйте функцию when из mocktail.

test(
      "Given a valid City object, When setCityAsFavorite is called and no error occurs, Then markCityAsFavorite is called with LocalCity object",
      () {
    // Given
    final testCity = City(...);
    const testLocalCity = LocalCityCompanion(...);
    when(() => localCityMapper.map(testCity)).thenReturn(testLocalCity);
        when(() => weatherLocalService.markCityAsFavorite(city: testLocalCity)).thenAnswer((_) async {});

    // When

    // Then
  });
Вход в полноэкранный режим Выход из полноэкранного режима

После того как макет настроен, необходимо вызвать настоящую тестируемую функцию. Вызовите функцию setCityAsFavorite, указав в качестве аргумента testCity. Поскольку setCityAsFavorite является асинхронной, пометьте тестовую функцию как асинхронную.

test(
      "Given a valid City object, When setCityAsFavorite is called and no error occurs, Then markCityAsFavorite is called with LocalCity object",
      () async {
    // Given
    ...
        ...

    // When
    await weatherRepository.setCityAsFavorite(testCity);

    // Then
  });

Вход в полноэкранный режим Выход из полноэкранного режима

Последним шагом в тестировании является проверка того, что были сделаны ожидаемые вызовы функций и возвращены ожидаемые данные. Поскольку функция, которую вы здесь тестируете, ничего не возвращает, мы рассмотрим, как проверить это в одном из последующих тестовых примеров. А пока давайте проверим, что функция markCityAsFavorite и функция map были вызваны один раз с помощью функции verify.

test(
      "Given a valid City object, When setCityAsFavorite is called and no error occurs, Then markCityAsFavorite is called with LocalCity object",
      () async {
    // Given
    ...
        ...

    // When
    ...
        ...

    // Then
    verify(() => localCityMapper.map(testCity)).called(1);
    verify(() => weatherLocalService.markCityAsFavorite(city: testLocalCity)).called(1);

        // 1
    verifyNoMoreInteractions(localCityMapper);
    verifyNoMoreInteractions(weatherLocalService);
    verifyZeroInteractions(weatherRemoteService);
  });
Вход в полноэкранный режим Выход из полноэкранного режима
  • Функция verifyNoMoreInteractions проверяет, что на данном макете больше не происходит никаких вызовов функций.

  • Функция verifyZeroInteractions проверяет, что ни одна функция не была вызвана на данном макете за все время теста.

Теперь вы можете запустить тест, нажав на зеленую кнопку рядом с тестовой функцией или запустив flutter test.

Тест должен пройти. Вы должны увидеть аналогичный результат в консоли.

Ожидание результатов в модульном тесте

Создайте новый тест для функции getFavoriteCitiesList. Структура будет такой же, как и раньше: создание тестовых данных, подражание возвращаемым значениям функции, вызов тестируемой функции, проверка ожидаемых вызовов функции.

test(
      "Given local service returns list of LocalCityData, When getFavoriteCitiesList is called, Then Future> is returned",
      () async {
    // Given
    final localCityData = [
      LocalCityData(
        woeid: 1,
        title: "title",
        locationType: "locationType",
        location: "location",
      )
    ];
    final cityData = [
      City(
        id: 1,
        title: "title",
        locationType: "locationType",
        location: "location",
      )
    ];
    when(() => weatherLocalService.getFavouriteCities())
        .thenAnswer((_) => Future.value(localCityData));
    when(() => domainCityMapper.mapList(localCityData)).thenReturn(cityData);

    // When
    final result = await weatherRepository.getFavoriteCitiesList();

    // Then
    verify(() => weatherLocalService.getFavouriteCities()).called(1);
    verify(() => domainCityMapper.mapList(localCityData)).called(1);
  });

Вход в полноэкранный режим Выход из полноэкранного режима

Текущая тестируемая функция возвращает результат. Важно проверить, что возвращаемое значение соответствует ожидаемому. Для этого мы воспользуемся функцией expect. Функция expect может проверять диапазон значений, подробнее о ней вы можете прочитать здесь.

Добавьте следующие функции expect для проверки правильности возвращаемых результатов. В первой expect мы проверяем длину возвращаемого списка, а во второй expect мы проверяем весь результат.

test(
      "Given local service returns list of LocalCityData, When getFavoriteCitiesList is called, Then Future> is returned",
      () async {
    // Given
    ...

    // When
    ...

    // Then
        ...
    expect(result.length, localCityData.length);
    expect(result, cityData);
  });
Вход в полноэкранный режим Выход из полноэкранного режима

Выполнение этого теста должно дать следующий результат

Ожидание исключений

В некоторых ситуациях вы можете захотеть проверить, не выбросит ли определенная функция исключение. Создайте еще один тест для setCityAsFavorite. Вы будете проверять условие, при котором, если localCityMapper выбрасывает исключение, это исключение будет выведено на экран функцией setCityAsFavorite. Для этого с помощью метода thenThrow вы дадите команду макету выбросить исключение вместо того, чтобы вернуть значение.

test(
      "Given setCityAsFavorite is called with a valid City object, When localCityMapper throws an exception, Then the same exception is surfaced to the caller",
          () async {
        // Given
        final testCity = City(
          id: 1,
          title: "title",
          locationType: "locationType",
          location: "location",
        );
        final testException = Exception("Test exception");
        when(() => localCityMapper.map(testCity)).thenThrow(testException);

        // When


        // Then

      });
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы проверить, выбрасывает ли функция исключение, необходимо объединить шаги when и then в один блок expect.

test(
      "Given setCityAsFavorite is called with a valid City object, When localCityMapper throws an exception, Then the same exception is surfaced to the caller",
      () async {
    // Given
    ...
        ...

    expect(
      // When
      () async => await weatherRepository.setCityAsFavorite(testCity),
      // Then
      throwsA(same(testException)),
    );
  });

Вход в полноэкранный режим Выход из полноэкранного режима

Вот и все! Теперь вы можете писать модульные тесты для любой ситуации.

Как вы могли заметить, многие тесты требуют одинаковых фиктивных данных. Вы можете извлечь эти объекты фиктивных тестовых данных в отдельный файл для удобства повторного использования.

Куда двигаться дальше?

Чтобы получить полный код из этого руководства, проверьте ветку unit-test-end в репозитории или просмотрите ее на GitHub.

Вы также можете ознакомиться с шаблоном среды Flutter, на котором основан этот репозиторий.

Во второй части этой серии мы рассмотрим тестирование виджетов Flutter.

Надеюсь, вам понравилось читать эту статью, и если у вас есть свои наработки, которыми вы хотите поделиться, пожалуйста, напишите нам в твиттере здесь!

Originally appeared on: https://www.wednesday.is/writing-tutorials/tests-in-flutter-part-1-how-to-write-unit-tests-in-flutter

Об авторе

Мастер приложений для Android и Flutter, Шунак в течение дня руководит командами мобильных разработчиков. Любитель игр, вы всегда можете найти его перед экраном в поисках хорошего фильма для просмотра. В свободное время Шунак увлекается фотографией и старается запечатлеть различные пейзажи на закате.

Оцените статью
Procodings.ru
Добавить комментарий