Модульное тестирование во Flutter: от основ рабочего процесса до сложных сценариев
Опубликовано: 2022-09-21Интерес к Flutter находится на рекордно высоком уровне, и он давно назрел. SDK Google с открытым исходным кодом совместим с Android, iOS, macOS, Интернетом, Windows и Linux. Единая кодовая база Flutter поддерживает их все. А модульное тестирование играет важную роль в создании согласованного и надежного приложения Flutter, защищая от ошибок, недостатков и дефектов за счет упреждающего улучшения качества кода до его сборки.
В этом руководстве мы расскажем об оптимизации рабочего процесса для модульного тестирования Flutter, продемонстрируем базовый модульный тест Flutter, а затем перейдем к более сложным тестовым случаям и библиотекам Flutter.
Поток модульного тестирования во Flutter
Мы реализуем модульное тестирование во Flutter почти так же, как и в других технологических стеках:
- Оцените код.
- Настроить мокирование данных.
- Определите тестовую группу (группы).
- Определите сигнатуры тестовых функций для каждой тестовой группы.
- Пишите тесты.
Чтобы продемонстрировать модульное тестирование, я подготовил пример проекта Flutter и призываю вас использовать и тестировать код на досуге. Проект использует внешний API для получения и отображения списка университетов, которые мы можем отфильтровать по стране.
Несколько замечаний о том, как работает Flutter: фреймворк упрощает тестирование, автоматически загружая библиотеку flutter_test
при создании проекта. Библиотека позволяет Flutter читать, запускать и анализировать модульные тесты. Flutter также автоматически создает test
папку для хранения тестов. Крайне важно избегать переименования и/или перемещения test
папки, так как это нарушает ее функциональность и, следовательно, нашу способность запускать тесты. Также важно включать _test.dart
в имена наших тестовых файлов, так как этот суффикс позволяет Flutter распознавать тестовые файлы.
Структура тестового каталога
Чтобы продвигать модульное тестирование в нашем проекте, мы реализовали MVVM с чистой архитектурой и внедрением зависимостей (DI), о чем свидетельствуют имена, выбранные для подпапок исходного кода. Сочетание принципов MVVM и DI обеспечивает разделение задач:
- Каждый класс проекта поддерживает одну цель.
- Каждая функция внутри класса выполняет только свою собственную область видимости.
Мы создадим организованное пространство для хранения тестовых файлов, которые мы напишем, систему, в которой группы тестов будут иметь легко идентифицируемые «дома». В свете требования Flutter размещать тесты в test
папке, давайте отразим структуру папок нашего исходного кода под test
. Затем, когда мы напишем тест, мы сохраним его в соответствующей подпапке: так же, как чистые носки помещаются в ящик для носков вашего комода, а сложенные рубашки — в ящик для рубашек, модульные тесты классов Model
помещаются в папку с именем model
. , Например.
Принятие этой файловой системы обеспечивает прозрачность проекта и дает команде простой способ просмотреть, какие части нашего кода связаны с тестами.
Теперь мы готовы приступить к модульному тестированию.
Простой модульный тест флаттера
Мы начнем с классов model
(на уровне data
исходного кода) и ограничим наш пример включением только одного класса model
, ApiUniversityModel
. Этот класс может похвастаться двумя функциями:
- Инициализируйте нашу модель, смоделировав объект JSON с помощью
Map
. - Создайте модель данных
University
.
Чтобы протестировать каждую из функций модели, мы настроим универсальные шаги, описанные ранее:
- Оцените код.
- Настройте имитацию данных: мы определим ответ сервера на наш вызов API.
- Определите группы тестов: у нас будет две группы тестов, по одной для каждой функции.
- Определите сигнатуры тестовых функций для каждой тестовой группы.
- Пишите тесты.
После оценки нашего кода мы готовы выполнить вторую задачу: настроить имитацию данных для двух функций в классе 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, либо из командной строки.
В терминале мы можем запустить все тесты, содержащиеся в 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
), поэтому мы создадим только одну тестовую группу. Мы проверим реакцию нашей функции на три ситуации:
- Действительно ли функция класса
Dio
getUniversitiesByCountry
? - Что произойдет, если наш запрос API вернет ошибку?
- Что произойдет, если наш запрос 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 выражает благодарность Матии Бециревич и Полу Хоскинсу за рассмотрение примеров кода и других технических материалов, представленных в этой статье.