Тестирование Flutter-приложений: гайд по разработке тестов на Flutter

Привет! Меня зовут Юрий Петров, я Flutter Team Lead в Friflex. Мы разрабатываем мобильные приложения для бизнеса и специализируемся на Flutter. В статье я расскажу про тестирование Flutter-проектов. Это гайд для новичков. Для понимания рекомендую полностью повторить процесс написания кода, который здесь демонстрируется. Готов ответить на любые вопросы по теме.

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

Что такое тестирование

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

Давайте представим, что у вас есть опубликованное рабочее  приложение, которое ежедневно используют миллионы пользователей. Перед вами стоит задача добавления новой функциональности. Вы успешно реализовываете ее и отправляете на тестирование в группу QA. Они проверяют эту функциональность и дают разрешение на публикацию обновления. Публикация проходит, и через какое-то время вы получаете резкий скачок ошибок в аналитике. При разборе данной ситуации становится понятно, что добавленная функциональность при определенных условиях воздействовала на основную функциональность приложения и вызывала ошибки. Это простой пример регрессии вашего приложения.

Разработка через тестирование (test-driven development, TDD)

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

Мы создаем функцию sum и пока не знаем, какая у нее будет  реализация.

int? sum(int a, int b) {  return null; }

Для решения этой задачи пишем простой тест с параметрами «5» и «6». Который, конечно же,  не проходит и выдает ошибку: ожидалось «11», а пришел null.

test("Разработка через тестирование TDD", () {  final result = sum(5, 6);  expect(result, 11); });

Далее реализуем функцию sum, которая возвращает сумму.

int? sum(int a, int b) {  return a + b; }

Запускаем тест, и он проходит. Таким образом, мы разработали функцию sum через тестирование. 

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

KISS  — «Делай проще»  (англ. keep it simple, stupid, KISS);

YAGNI — «Вам это не понадобится» (англ. you aren’t gonna need it, YAGNI).

Таким образом, код становится более чистым, масштабируемым и читаемым.

И еще пара рекомендаций о том, как нужно делать тесты:

  • Исключите прямые зависимости. Здесь все просто: ваши тесты не должны зависеть друг от друга. Вы можете проверить себя таким образом: если у вас есть 10 тестов, которые успешно проходят, измените очередность их запуска. Тесты стали выдавать ошибки? Это значит, что они написаны  с ошибками.

При написании тестов придерживайтесь правила «Один тест — одна функция».

Три типа тестирования Flutter-приложений

Фреймворк Flutter предоставляет разработчику полный набор инструментов  для тестирования приложений: unit test (модульный тест), widget test (тест виджетов) и integration test (интеграционный тест).

Каждый тест решает задачу на своем уровне. 

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

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

Интеграционный тест тестирует все приложение или его большую часть. Цель интеграционного теста — убедиться, что все тестируемые виджеты и сервисы работают вместе, как ожидалось. Кроме того, вы можете использовать интеграционные тесты для проверки производительности вашего приложения. Как правило, интеграционный тест выполняется на реальном устройстве или эмуляторе.

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

Например, в нашей компании pull-request будет сразу же отклонен, если в нем содержатся какие-нибудь публичные top-level функции, не покрытые тестами. Также мы всегда стараемся покрывать тестами общие виджеты, которые используются во всем приложении. Это очень помогает, когда новый разработчик в команде вносит изменения в такой виджет, думая, что этим он никак не может повлиять на его работу. Помните, выше я писал про регрессионное тестирование? Вот вам еще один пример.

Про интеграционный тест можно сказать, что мало кому хочется запускать его, потому что ждать, пока он выполнится, придется долго. Но если у вас в проекте настроены CI/CD инструменты (GitHub Action и др.), то тесты нужно сделать. Потому что  в дальнейшем это сократит время на отладку приложения.

Есть таблица компромисса, которая была разработана умными дядьками из Google. С ее помощью вы можете решить, что для вас важнее — время или качество.

Таблица компромисса

Например, мы видим, что модульное тестирование дает нам низкую уверенность в приложении, но при этом модульные тесты легко реализуются и быстро выполняются. Если говорить про интеграционные тесты, то здесь ситуация прямо противоположная. Уверенность самая высокая, а реализация и выполнение медленные.

Приступаем к практике

Я написал небольшое приложение, которое позволяет получить информацию о погоде через общедоступный ресурс https://openweathermap.org. Приложение очень простое, но его будет достаточно, чтобы разобраться в тестировании Flutter-проектов.

Для работы вам необходимо клонировать данное приложение из репозитория на GitHub https://github.com/petrovyuri/flutter_testing. Для того чтобы оно заработало, нужно получить appId на сайте https://openweathermap.org и вставить его в network_data_repository.dart

Ура! Теперь у нас есть рабочее приложение, которое можно покрыть тестами.

Вот здесь замените appID на свой

Внешний вид приложения

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

static String convertHumanTemp(double? temperature) {  try {    final value = int.parse(temperature.toString().split(".").first);    return "$value °C";  } catch (error) {    return "Ошибка данных";  } }

В этом случае нам необходимо применить unit-тестирование.

Для этого в папке test (она уже должна быть у вас, если такой папки нет, то это очень странно))) создадим файл app_test.dart. Это будет общий файл, откуда мы будем запускать все наши тесты. Далее в этой же папке создадим файл unit_test.dart. В итоге у вас должно получиться что-то вроде этого:

Для каждого теста лучше создать отдельный файл: так будет удобнее. Отлично, теперь в файле unit_test.dart создадим наш первый тест для функции convertHumanTemp().

void main() {  test('Проверка функции на  стандартные значения', () {    final result = Utilities.convertHumanTemp(27.3);    expect(result, equals("27 °C"));  });   test('Проверка функции на  null', () {    final result = Utilities.convertHumanTemp(null);    expect(result, "Ошибка данных");  }); }

Разберем этот тест.

Для тестирования функции нам необходимо значение result, которое будет проинициализировано значением из функции Utilities.convertHumanTemp(). В саму функцию мы передаем значение 27.3. Далее с помощью встроенной функции expect мы можем проверить result с матчером. В первом случае это строка «27 °C» с валидными данными. Во втором случае мы передаем в функцию null, ожидая получить строку «Ошибка данных». Если в таком виде запустить тест, то он успешно пройдет.

 А если попробовать внести изменения в первый тест таким образом:

test('Проверка функции на  стандартные значения', () {  final result = Utilities.convertHumanTemp(27.3);  expect(result, equals("30 °C")); });

То мы получим ошибку прохождения теста:

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

Тестирование виджетов

Теперь попробуем разобрать тестирование виджетов. Для этого создадим файл widget_test.dart. Что нам необходимо проверить: 

  1. Экран до получения информации;

  2. Экран с уже  полученной информацией.

И здесь уже не все так просто и интуитивно понятно, как, например, в юнит-тестах. Дело в том, что нам каким-то образом надо взаимодействовать с виджетами на экране. Здесь я имею в  виду  тапы по кнопкам, ввод текста в текстовые поля и так далее. Для этого есть класс WidgetTester, который  программно взаимодействует  с виджетами и тестовой средой. Чтобы получить данный объект, нам необходимо создать тест. Почти такой же, как юнит-тест, только здесь мы будем использовать функцию testWidgets. 

Попробуем написать и разобрать наш первый тест.

   testWidgets("Проверка начального экрана приложения",         (WidgetTester tester) async {         /// Тело теста     });

Мы видим, что testWidgets возвращает нам объект, tester — объект WidgetTester. Теперь мы может делать все что угодно с нашими виджетами. Первое, что нам нужно сделать — получить ссылку на наш репозиторий, так как виджет AppScreen принимает репозиторий в конструктор. Но здесь есть нюанс: при тестировании мы не должны обращаться к реальному источнику данных, так как они могут быть не достоверны.

Для этого используют мок-репозитории. В нашем случае у нас уже написан репозиторий, который всегда возвращает число «36.3».

class MockDataRepository implements DataRepository {  @override  Future<DataModel> fetchData() {    return Future(() => const DataModel(temp: 36.3));  } }

Для удобства работы с мок-репозиториями я настоятельно рекомендую научиться работать с такими библиотеками как Mockito или Mocktail.

Продолжаем писать наш виджет тест. Инициализируем в теле теста мок-репозиторий в тесте:

final repository = MockDataRepository();

C помощью объекта tester добавляем в тестовую среду виджет AppScreen методом pumpWidget:

await tester.pumpWidget(MaterialApp(                            home: AppScreen(repository: repository))); 

Далее нам необходимо найти текст «Обновите данные» в тестовой среде. Для этого мы используем класс Finder. Искать можно разными способами и используя разные finders. Вот самые распространенные из них (на самом деле их намного больше). Также вы легко может сам написать свой finder.

find.text() — ищет текст

find.byKey() — ищет виджет по ключу

find.byType() — ищет виджет по типу

find.byIcon() — ищет виджет типа Icon

find.byWidgetPredicate() — ищет виджет по предикату

Соответственно мы можем найти текст и тип виджета FloatingActionButton.

final textFinder = find.text("Обновите данные"); final fabFinder = find.byType(FloatingActionButton);

Осталось сравнить с помощью класса Matcher полученный результат с заданным finders. Их тоже написано много: почти на все случае жизни. Но вы можете легко написать свои. Ниже приведены самые, на мой взгляд, используемые:

findsOneWidget — сравнивает, что [Finder] находит только один виджет

findsWidgets — сравнивает, что [Finder] находит по крайней мере один виджет 

isSameColorAs(Color color) — сравнивает, что объект имеет определенный цвет

findsNothing — сравнивает, что [Finder] не находит виджет 

isNotNull — сравнивает, что обьект не null

matchesGoldenFile — сравнивает, соответствует ли рендеринг виджета конкретному растровому изображению (тестирование «золотой файл»).

Далее с помощью уже знакомой функции expect проверяем результат.

expect(textFinder, findsOneWidget); expect(fabFinder, findsOneWidget);

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

testWidgets("Проверка начального экрана приложения",         (WidgetTester tester) async {       /// Инициализация репозитория       final repository = MockDataRepository();       /// Создание виджета       await tester           .pumpWidget(MaterialApp(home: AppScreen(repository:      repository)));       /// Поиск текста       final textFinder = find.text("Обновите данные");       /// Поиск кнопки       final fabFinder = find.byType(FloatingActionButton);       /// Сравнение finder с matcher       expect(textFinder, findsOneWidget);       expect(fabFinder, findsOneWidget); });

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

/// Имитация нажатия на кнопку await tester.tap(fabFinder); /// Перестройка интерфейса await tester.pumpAndSettle(); /// Поиск текста final tempTextFinder = find.text("Температура"); /// Поиск текста final currentTempTextFinder = find.text("36 °C"); /// Сравнение finder с matcher expect(tempTextFinder, findsOneWidget); expect(currentTempTextFinder, findsOneWidget);

Здесь в принципе все понятно: обращаемся к объекту tester, вызываем его метод tap и передаем в него fabFinder. Таким образом, мы имитируем нажатие на кнопку. Далее вызываем у объекта tester метод pumpAndSettle. Здесь необходимо остановиться подробнее. Дело в том, что в тестовой среде нам нужен полный контроль за перестройкой виджета (аналог setState()). Для этого у WidgetTester есть методы:

pump — немедленная перестройка виджета;

pumpAndSettle — повторяет вызовы pump() с заданной продолжительностью до тех пор, пока не исчезнут запланированные кадры.

А так как получение данных — это продолжительная операция, необходимо использовать pumpAndSettle. Если в данном случае вызвать pump, то вы получите ошибку.

Теперь ваш тест должен выглядеть вот так.

   testWidgets("Проверка начального экрана приложения",         (WidgetTester tester) async {       final repository = MockDataRepository();       await tester.pumpWidget(MaterialApp(           home: AppScreen(         repository: repository,       )));       final fabFinder = find.byType(FloatingActionButton);       expect(fabFinder, findsOneWidget);       await tester.tap(fabFinder);       await tester.pumpAndSettle();       final tempTextFinder = find.text("Температура");       final currentTempTextFinder = find.text("36 °C");       expect(tempTextFinder, findsOneWidget);       expect(currentTempTextFinder, findsOneWidget);   });

Группы тестов

Для удобства чтения теста вы можете объединять тесты в группы с помощью функции group.

В нашем случае можно объединить два теста в одну группу.

void main() {  group("AppScreen", () {    testWidgets("Проверка начального экрана приложения",        (WidgetTester tester) async {      final repository = MockDataRepository();      await tester          .pumpWidget(MaterialApp(home: AppScreen(repository: repository)));      final textFinder = find.text("Обновите данные");      final fabFinder = find.byType(FloatingActionButton);      expect(textFinder, findsOneWidget);      expect(fabFinder, findsOneWidget);    });     testWidgets("Проверка начального экрана приложения",        (WidgetTester tester) async {      final repository = MockDataRepository();      await tester.pumpWidget(MaterialApp(          home: AppScreen(        repository: repository,      )));      final fabFinder = find.byType(FloatingActionButton);      expect(fabFinder, findsOneWidget);      await tester.tap(fabFinder);      await tester.pumpAndSettle();      final tempTextFinder = find.text("Температура");      final currentTempTextFinder = find.text("36 °C");      expect(tempTextFinder, findsOneWidget);      expect(currentTempTextFinder, findsOneWidget);    });  }); }

Инициализация и удаление

Для удобства работы с тестами там, где необходимо создать и потом удалить общие для всех тестов объекты, можно использовать методы: 

setUp — функция вызывается перед каждым тестом;

setUpAll — функция вызывается однократно перед запуском всех тестов;

 tearDown — функция вызывается после каждого теста;

tearDownAll — функция вызывается однократно после всех тестов;

Если мы добавим в наш тест эти функции, то получим:

void main() {   setUp(() {     print('Запуск setUp');   });    tearDown(() {     print('Запуск tearDown');   });      testWidgets("Проверка начального экрана приложения",       (WidgetTester tester) async {   /// Первый тест   });    testWidgets("Проверка начального экрана приложения",       (WidgetTester tester) async { /// Второй тест   }); }

Интеграционные тесты

С приходом Flutter 2.5 интеграционные тесты стали намного проще. Но все равно есть действия, которые необходимо уметь делать. 

  1. Так как интеграционные тесты не входят в стандартный пакет flutter, нам необходимо добавить следующую запись в pubspec.yaml

  1. В корне проекта создать папку integration_test с файлом app_test.dart внутри.

  2. Написать такой же тест, как тот, который мы написали при тестировании виджетов, но с нескольким отличиями. Добавляем то выполнения тестов следующую строку:

void main() {  IntegrationTestWidgetsFlutterBinding.ensureInitialized();    /// далее тест }

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

Теперь наш файл  app_test.dart выглядит следующим образом:

 testWidgets("Проверка начального экрана приложения",      (WidgetTester tester) async {    final repository = MockDataRepository();    await tester        .pumpWidget(MaterialApp(home: AppScreen(repository: repository)));    final textFinder = find.text("Обновите данные");    final fabFinder = find.byType(FloatingActionButton);    expect(textFinder, findsOneWidget);    expect(fabFinder, findsOneWidget);    await Future.delayed(const Duration(seconds: 5));    await tester.tap(fabFinder);    await tester.pumpAndSettle();    final mainTextFinder = find.text("Иннополис");    final tempTextFinder = find.text("Температура");    final currentTempTextFinder = find.text("36 °C");    expect(mainTextFinder, findsOneWidget);    expect(tempTextFinder, findsOneWidget);    expect(currentTempTextFinder, findsOneWidget);    /// Здесь задержка нужна, только для того, что бы тест не закрылся сразу.    await Future.delayed(const Duration(seconds: 5));  }); }
  1. Теперь необходимо запустить наш тест. Набираем в терминале команду и ждем:

flutter test integration_test/app_test.dart

Если у вас есть желание дополнить мою статью — например, информацией о том, чем тестирование Flutter-проектов отличается от тестирования приложений на других фреймворках — жду вас в комментариях. 

Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/company/friflex/blog/666578/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *