Модульное тестирование во Flutter: от основ рабочего процесса до сложных сценариев

Опубликовано: 2022-09-21

Интерес к Flutter находится на рекордно высоком уровне, и он давно назрел. SDK Google с открытым исходным кодом совместим с Android, iOS, macOS, Интернетом, Windows и Linux. Единая кодовая база Flutter поддерживает их все. А модульное тестирование играет важную роль в создании согласованного и надежного приложения Flutter, защищая от ошибок, недостатков и дефектов за счет упреждающего улучшения качества кода до его сборки.

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

Поток модульного тестирования во Flutter

Мы реализуем модульное тестирование во Flutter почти так же, как и в других технологических стеках:

  1. Оцените код.
  2. Настроить мокирование данных.
  3. Определите тестовую группу (группы).
  4. Определите сигнатуры тестовых функций для каждой тестовой группы.
  5. Пишите тесты.

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

Несколько замечаний о том, как работает Flutter: фреймворк упрощает тестирование, автоматически загружая библиотеку flutter_test при создании проекта. Библиотека позволяет Flutter читать, запускать и анализировать модульные тесты. Flutter также автоматически создает test папку для хранения тестов. Крайне важно избегать переименования и/или перемещения test папки, так как это нарушает ее функциональность и, следовательно, нашу способность запускать тесты. Также важно включать _test.dart в имена наших тестовых файлов, так как этот суффикс позволяет Flutter распознавать тестовые файлы.

Структура тестового каталога

Чтобы продвигать модульное тестирование в нашем проекте, мы реализовали MVVM с чистой архитектурой и внедрением зависимостей (DI), о чем свидетельствуют имена, выбранные для подпапок исходного кода. Сочетание принципов MVVM и DI обеспечивает разделение задач:

  1. Каждый класс проекта поддерживает одну цель.
  2. Каждая функция внутри класса выполняет только свою собственную область видимости.

Мы создадим организованное пространство для хранения тестовых файлов, которые мы напишем, систему, в которой группы тестов будут иметь легко идентифицируемые «дома». В свете требования Flutter размещать тесты в test папке, давайте отразим структуру папок нашего исходного кода под test . Затем, когда мы напишем тест, мы сохраним его в соответствующей подпапке: так же, как чистые носки помещаются в ящик для носков вашего комода, а сложенные рубашки — в ящик для рубашек, модульные тесты классов Model помещаются в папку с именем model . , Например.

Структура файловых папок с двумя папками первого уровня: lib и test. Вложенная под lib у нас есть папка функций, дальше вложенные университеты_фид, а дальше вложенные данные. Папка данных содержит репозиторий и исходные папки. Под исходной папкой находится сетевая папка. Под сетью находятся папки конечной точки и модели, а также файл University_remote_data_source.dart. В папке с моделью находится файл api_university_model.dart. На том же уровне, что и ранее упомянутая папка University_feed, находятся папки домена и презентации. Под доменом находится папка usecase. Под презентацией находятся папки с моделями и экранами. Структура ранее упомянутой тестовой папки имитирует структуру lib. Под папкой test находится папка unit_test, которая содержит папкуUniversity_feed. Структура его папок такая же, как и в приведенной выше папке University_feed, с файлами dart, к именам которых добавлено «_test».
Структура тестовой папки проекта, отражающая структуру исходного кода

Принятие этой файловой системы обеспечивает прозрачность проекта и дает команде простой способ просмотреть, какие части нашего кода связаны с тестами.

Теперь мы готовы приступить к модульному тестированию.

Простой модульный тест флаттера

Мы начнем с классов model (на уровне data исходного кода) и ограничим наш пример включением только одного класса model , ApiUniversityModel . Этот класс может похвастаться двумя функциями:

  • Инициализируйте нашу модель, смоделировав объект JSON с помощью Map .
  • Создайте модель данных University .

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

  1. Оцените код.
  2. Настройте имитацию данных: мы определим ответ сервера на наш вызов API.
  3. Определите группы тестов: у нас будет две группы тестов, по одной для каждой функции.
  4. Определите сигнатуры тестовых функций для каждой тестовой группы.
  5. Пишите тесты.

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

Чтобы смоделировать первую функцию (инициализация нашей модели путем имитации JSON с помощью Map ), fromJson , мы создадим два объекта Map для имитации входных данных для функции. Мы также создадим два эквивалентных объекта ApiUniversityModel для представления ожидаемого результата функции с предоставленными входными данными.

Чтобы смоделировать вторую функцию (построение модели данных University ), toDomain , мы создадим два объекта University , которые являются ожидаемым результатом после запуска этой функции в ранее созданных объектах ApiUniversityModel :

 void main() { Map<String, dynamic> apiUniversityOneAsJson = { "alpha_two_code": "US", "domains": ["marywood.edu"], "country": "United States", "state-province": null, "web_pages": ["http://www.marywood.edu"], "name": "Marywood University" }; ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel( alphaCode: "US", country: "United States", state: null, name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); University expectedUniversityOne = University( alphaCode: "US", country: "United States", state: "", name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); Map<String, dynamic> apiUniversityTwoAsJson = { "alpha_two_code": "US", "domains": ["lindenwood.edu"], "country": "United States", "state-province":"MJ", "web_pages": null, "name": "Lindenwood University" }; ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel( alphaCode: "US", country: "United States", state:"MJ", name: "Lindenwood University", websites: null, domains: ["lindenwood.edu"], ); University expectedUniversityTwo = University( alphaCode: "US", country: "United States", state: "MJ", name: "Lindenwood University", websites: [], domains: ["lindenwood.edu"], ); }

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

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from JSON", () { test('Test using json one', () {}); test('Test using json two', () {}); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () {}); test('Test toDomain using json two', () {}); }); }

Мы определили сигнатуры двух тестов для проверки функции fromJson и двух тестов для проверки функции toDomain .

Чтобы выполнить нашу пятую задачу и написать тесты, давайте воспользуемся методом ожидания библиотеки expect , чтобы сравнить результаты функций с нашими ожиданиями:

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from json", () { test('Test using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson), expectedApiUniversityOne); }); test('Test using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson), expectedApiUniversityTwo); }); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(), expectedUniversityOne); }); test('Test toDomain using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(), expectedUniversityTwo); }); }); }

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

Скриншот, показывающий, что пять тестов из пяти пройдены. Заголовок гласит: Выполнить: тесты в api_university_model_test.dart. Левая панель экрана гласит: Результаты теста --- загрузка api_university_model_test.dart --- api_university_model_test.dart --- Инициализация теста ApiUniversityModel из json --- Тест с использованием json one --- Тест с использованием json two --- Тесты ApiUniversityModel toDomain --- Протестируйте toDomain, используя json one --- Протестируйте toDomain, используя json two. Правая панель экрана гласит: Тесты пройдены: пять из пяти тестов --- флаттер-тест test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

В терминале мы можем запустить все тесты, содержащиеся в test папке, введя команду flutter test , и убедиться, что наши тесты пройдены.

В качестве альтернативы мы могли бы запустить один тест или группу тестов, введя команду flutter test --plain-name "ReplaceWithName" , заменив ReplaceWithName именем нашего теста или группы ReplaceWithName .

Модульное тестирование конечной точки во Flutter

Выполнив простой тест без зависимостей, давайте рассмотрим более интересный пример: мы протестируем класс endpoint , область действия которого включает:

  • Выполнение вызова API к серверу.
  • Преобразование ответа API JSON в другой формат.

После оценки нашего кода мы будем использовать метод setUp библиотеки setUp для инициализации классов в нашей тестовой группе:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }

Чтобы делать сетевые запросы к API, я предпочитаю использовать модифицированную библиотеку, которая генерирует большую часть необходимого кода. Чтобы должным образом протестировать класс UniversityEndpoint , мы заставим библиотеку dio, которую Retrofit использует для выполнения вызовов API, возвращать желаемый результат, имитируя поведение класса Dio через настраиваемый адаптер ответа.

Пользовательский макет сетевого перехватчика

Насмешки возможны из-за того, что мы создали класс UniversityEndpoint через DI. (Если бы класс UniversityEndpoint сам инициализировал класс Dio , у нас не было бы возможности имитировать поведение класса.)

Чтобы имитировать поведение класса Dio , нам нужно знать методы Dio , используемые в библиотеке Retrofit , но у нас нет прямого доступа к Dio . Поэтому мы будем издеваться над Dio , используя собственный перехватчик сетевых ответов:

 class DioMockResponsesAdapter extends HttpClientAdapter { final MockAdapterInterceptor interceptor; DioMockResponsesAdapter(this.interceptor); @override void close({bool force = false}) {} @override Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future? cancelFuture) { if (options.method == interceptor.type.name.toUpperCase() && options.baseUrl == interceptor.uri && options.queryParameters.hasSameElementsAs(interceptor.query) && options.path == interceptor.path) { return Future.value(ResponseBody.fromString( jsonEncode(interceptor.serializableResponse), interceptor.responseCode, headers: { "content-type": ["application/json"] }, )); } return Future.value(ResponseBody.fromString( jsonEncode( {"error": "Request doesn't match the mock interceptor details!"}), -1, statusMessage: "Request doesn't match the mock interceptor details!")); } } enum RequestType { GET, POST, PUT, PATCH, DELETE } class MockAdapterInterceptor { final RequestType type; final String uri; final String path; final Map<String, dynamic> query; final Object serializableResponse; final int responseCode; MockAdapterInterceptor(this.type, this.uri, this.path, this.query, this.serializableResponse, this.responseCode); }

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

В нашем случае у нас есть только одна функция для тестирования ( getUniversitiesByCountry ), поэтому мы создадим только одну тестовую группу. Мы проверим реакцию нашей функции на три ситуации:

  1. Действительно ли функция класса Dio getUniversitiesByCountry ?
  2. Что произойдет, если наш запрос API вернет ошибку?
  3. Что произойдет, если наш запрос API вернет ожидаемый результат?

Вот наша тестовая группа и сигнатуры тестовых функций:

 group("Test University Endpoint API calls", () { test('Test endpoint calls dio', () async {}); test('Test endpoint returns error', () async {}); test('Test endpoint calls and returns 2 valid universities', () async {}); });

Мы готовы писать наши тесты. Для каждого теста мы создадим экземпляр DioMockResponsesAdapter с соответствующей конфигурацией:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); test('Test endpoint calls dio', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, [], ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, <ApiUniversityModel>[]); }); test('Test endpoint returns error', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 404, {"error": "Not found!"}, ); List<ApiUniversityModel>? response; DioError? error; try { response = await endpoint.getUniversitiesByCountry("us"); } on DioError catch (dioError, _) { error = dioError; } expect(response, null); expect(error?.error, "Http status error [404]"); }); test('Test endpoint calls and returns 2 valid universities', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, generateTwoValidUniversities(), ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, expectedTwoValidUniversities()); }); });

Теперь, когда наше тестирование конечной точки завершено, давайте протестируем наш класс источника данных, UniversityRemoteDataSource . Ранее мы заметили, что класс UniversityEndpoint является частью конструктора UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) , что указывает на то, что UniversityRemoteDataSource использует класс UniversityEndpoint для выполнения своей области действия, поэтому именно этот класс мы будем имитировать.

Издевательство над Мокито

В нашем предыдущем примере мы вручную имитировали адаптер запросов нашего клиента Dio , используя собственный NetworkInterceptor . Здесь мы издеваемся над целым классом. Делать это вручную — имитировать класс и его функции — потребовало бы много времени. К счастью, фиктивные библиотеки предназначены для обработки таких ситуаций и могут генерировать фиктивные классы с минимальными усилиями. Давайте воспользуемся библиотекой mockito, стандартной отраслевой библиотекой для моков во Flutter.

Для имитации через Mockito мы сначала добавляем аннотацию « @GenerateMocks([class_1,class_2,…]) » перед кодом теста — прямо над функцией void main() {} . В аннотацию мы включим список имен классов в качестве параметра (вместо class_1,class_2… ).

Затем мы запускаем flutter pub run build_runner build , которая генерирует код для наших фиктивных классов в том же каталоге, что и тест. Имя результирующего фиктивного файла будет состоять из имени тестового файла и .mocks.dart , заменяя суффикс .dart . Содержимое файла будет включать фиктивные классы, имена которых начинаются с префикса Mock . Например, UniversityEndpoint становится MockUniversityEndpoint .

Теперь мы импортируем university_remote_data_source_test.dart.mocks.dart (наш фиктивный файл) в university_remote_data_source_test.dart (тестовый файл).

Затем в функции setUp мы будем имитировать UniversityEndpoint , используя MockUniversityEndpoint и инициализируя класс UniversityRemoteDataSource :

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); }

Мы успешно смоделировали UniversityEndpoint , а затем инициализировали наш класс UniversityRemoteDataSource . Теперь мы готовы определить наши тестовые группы и сигнатуры тестовых функций:

 group("Test function calls", () { test('Test dataSource calls getUniversitiesByCountry from endpoint', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {}); });

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

Наш первый тест проверяет, вызывается ли функция UniversityEndpoint , когда источник данных инициирует получение информации о стране. Начнем с определения того, как каждый класс будет реагировать на вызов его функций. Поскольку мы смоделировали класс UniversityEndpoint , мы будем работать с этим классом, используя структуру кода when( function_that_will_be_called ).then( what_will_be_returned ) .

Функции, которые мы тестируем, являются асинхронными (функции, которые возвращают объект Future ), поэтому мы будем использовать структуру кода when(function name).thenanswer( (_) {modified function result} ) для изменения наших результатов.

Чтобы проверить, вызывает ли функция getUniversitiesByCountry функцию getUniversitiesByCountry в классе UniversityEndpoint , мы будем использовать when(...).thenAnswer( (_) {...} ) , чтобы имитировать функцию getUniversitiesByCountry в классе UniversityEndpoint :

 when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

Теперь, когда мы имитировали наш ответ, мы вызываем функцию источника данных и проверяем — с помощью функции verify — была ли вызвана функция UniversityEndpoint :

 test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });

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

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); }); test('Test dataSource maps getUniversitiesByCountry response to Stream', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), const AppResult<List<University>>.data([]) ]), ); }); test( 'Test dataSource maps getUniversitiesByCountry response to Stream with error', () { ApiError mockApiError = ApiError( statusCode: 400, message: "error", errors: null, ); when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.error(mockApiError)); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), AppResult<List<University>>.apiError(mockApiError) ]), ); }); }); }

Мы выполнили ряд модульных тестов Flutter и продемонстрировали различные подходы к имитации. Я приглашаю вас продолжать использовать мой образец проекта Flutter для проведения дополнительного тестирования.

Модульные тесты Flutter: ваш ключ к превосходному UX

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

Редакция блога Toptal Engineering выражает благодарность Матии Бециревич и Полу Хоскинсу за рассмотрение примеров кода и других технических материалов, представленных в этой статье.