Про Flutter вспоминают тогда, когда нужно быстро сделать красивое и отзывчивое приложение сразу для нескольких платформ, но как гарантировать качество «быстрого» кода?
Вы удивитесь, но во Flutter есть средства для того, чтобы не только обеспечить качество кода, но и гарантировать работоспособность визуального интерфейса.
В статье рассмотрим, как обстоят дела с тестами на Flutter, разберем виджет-тесты и интеграционное тестирование приложения в целом.  Я начал изучать Flutter больше года назад, еще до его официального релиза, во время изучения не было проблемой найти какую-либо информацию по разработке. А когда захотелось попробовать TDD, выяснилось, что информации о тестировании катастрофически мало. На русском языке, так и вообще, практически нет. Вопросы тестирования пришлось изучать самостоятельно, по исходникам тестов Flutter и редким статьям на английском языке. Все что изучил по тестированию визуальных элементов, я описал в статье, чтобы помочь тем, кто только начинает вникать в тему. Виджет-тест (widget test) тестирует один виджет. Его еще можно назвать компонентным тестом (component test). Целью теста является доказательство того, что пользовательский интерфейс виджета выглядит и взаимодействует так, как это запланировано. Тестирование виджета требует тестовую среду, которая обеспечивает соответствующий контекст жизненного цикла виджета.
Тестируемый виджет имеет возможность получать действия и события пользователя, и реагировать на них, строить дерево дочерних виджетов. Поэтому виджет-тесты является более сложным, чем unit-тесты. Однако, как и unit-тест, среда тестирования виджетов является простой имитацией, намного более простой, чем полноценная система пользовательского интерфейса. Виджет-тестирование позволяет изолировать и проверить поведение отдельно взятого элемента визуального интерфейса. И, что примечательно, осуществить все проверки в консоли, что идеально для тестов, которые запускаются как этап CI/CD процесса. Файлы, которые содержат тесты, принято располагать в подкаталоге test проекта.
Тесты можно запустить либо из IDE, либо из консоли командой: В этом случае выполнятся все тесты с маской *_test.dart из подкаталога test.
Можно запустить отдельный тест, указав имя файла: Тест создается функцией testWidgets, которая в качестве параметра tester получает инструмент, с помощью которого код теста взаимодействует с тестируемым виджетом: Для объединения тестов в логические блоки, тестовые функции можно объединять в группы, внутри функции group: Функции setUp и tearDown позволяют выполнить некоторый код «до» и «после» каждого теста. Соответственно, функции setUpAll и tearDownAll позволяют выполнить код «до» и «после» всех тестов, а если эти функции вызванный внутри группы, то они будут вызваны «до» и «после» выполнения всех тестов группы: Для того, чтобы выполнить какие-то действия над вложенным виджетом, его нужно найти в дереве виджетов. Для этого есть глобальный объект find, который позволяет найти виджеты: Класс WidgetTester предоставляет функции для создания тестируемого виджета, ожидания смены его состояний и для выполнения некоторых действий над этими виджетами.
Любое изменение виджета вызывает изменение его состояния. Но тестовая среда не перестраивает виджет при этом. Нужно самостоятельно указать тестовой среде, что требуется перестроить виджет, путем вызова функций pump или pumpAndSettle. Тесты могут реализовывать как позитивные сценарии, проверяя запланированные возможности, так и негативные, чтобы убедиться, что они не приводят к фатальным последствиям, например, когда пользователь нажимает не туда, куда надо, и вводит не то, что требуется: После любых действий с виджетами нужно вызывать tester.pumpAndSettle() для смены состояний. Многие знакомы с библиотекой Mockito. Эта библиотека из мира Java оказалась столь удачной, что есть реализации этой библиотеки на многих языках программирования, в том числе и на Dart. Для подключения необходимо добавить зависимость в проект. Добавляем следующие строки в файл pubspec.yaml: И подключить в файле с тестами: Эта библиотека позволяет создать моковые классы, от которых зависит тестируемый виджет, для того, чтобы тест был более простым и охватывал только тот код, который мы тестируем.
Например, если мы тестируем виджет PhoneInputScreen, который при нажатии, используя AuthInteractor сервис, выполняет запрос к бэкенду authInteractor.checkAccess(), то подставив мок вместо сервиса, мы сможем проверить самое главное — наличие факта обращения к этому сервису. Моки зависимостей создаются как наследники класса Mock и реализуют интерфейс зависимости: Класс в Dart является одновременно и интерфейсом, поэтому нет необходимости декларировать интерфейс отдельно, как в некоторых других языках программирования. Для определения функциональности мока используется функция when, которая позволяет определить ответ мока на вызов той или иной функции: Моки могут возвращать ошибки или ошибочные данные: В процессе выполнения теста можно проверить наличие виджетов на экране. Это позволяет убедиться в том, что новое состояние экрана корректно с точки зрения видимости нужных виджетов: После выполнения теста, также можно проверить какие методы мокового класса вызывались в ходе теста, и сколько раз. Это необходимо, например, для понимания того, не слишком ли часто запрашиваются те или иные данные, нет ли лишних изменений состояния приложения: Тесты выполняются в консоли без какой-либо графики. Можно запускать тесты в debug режиме и ставить точки останова в коде виджета. Чтобы получить представление о том, что происходит в дереве виджетов, можно использовать функцию debugDumpApp(), которая, будучи вызвана в коде теста, выводит в консоль текстовое представление иерархии всего дерева виджетов в данный момент времени. Чтобы понять, как виджет использует моки есть функция logInvocations(). Она в качестве параметра принимает список моков и выдает в консоль последовательность вызовов методов у этих моков, которые осуществлялись в тесте. Пример такого вывода ниже. Отметка VERIFIED есть у вызовов, которые проверялись в тесте с помощью функции verify: Все зависимости должны подаваться в тестируемый виджет в виде мока: Передача зависимостей в тестируемый компонент должна осуществляться каким-либо образом, принятым в вашем приложении. Для простоты повествования рассмотрим пример, когда зависимости передаются через конструктор. В примере кода PhoneInputScreen — тестируемый виджет, на основе StatefulWidget, завернутый в Scaffold. Он создается в тестовом окружении с помощью функции pumpWidget(): Однако реальный виджет может использовать выравнивание для вложенных виджетов, что требует наличия MediaQuery в дереве виджетов, возможно получает Navigator.of(context) для навигации, поэтому практичнее заворачивать тестируемый виджет в MaterialApp или CupertinoApp: После создания тестового виджета и после любых действий с ним нужно вызывать tester.pumpAndSettle() для того, чтобы тестовое окружение обработало все смены состояний виджета. В отличии от виджет-тестов, интеграционный тест проверяет полностью все приложение или какую-то большую его часть. Цель интеграционного теста заключается в том, чтобы убедиться, что все виджеты и сервисы работают вместе так, как и ожидалось. Процесс работы интеграционного теста можно наблюдать в симуляторе или на экране устройства. Этот метод хорошо заменяет ручное тестирование. Кроме того, можно использовать интеграционные тесты для проверки производительности приложения.  Интеграционный тест, как правило, выполняется на реальном устройстве или эмуляторе, таком как iOS Simulator или Android Emulator. Файлы, содержащие интеграционные тесты, принято располагать в подкаталоге test_driver проекта. Приложение изолировано от кода тестового драйвера и запускается после него. Тестовый драйвер позволяет управлять приложением во время теста. Выглядит это следующим образом: Запуск тестов осуществляется из командной строки. Если запуск целевого приложения описан в файле app.dart, а тестовый сценарий называется app_test.dart, то достаточно следующей команды: Если тестовый сценарий имеет другое имя, тогда его нужно указать явно: Тест создается функцией test, и группируются функцией group. В этом примере показан код создания тестового драйвера, через который тесты взаимодействуют с тестируемым приложением. Инструмент FlutterDriver взаимодействует с тестируемым приложением через следующие методы: Возможна ситуация, когда потребуется оказать влияние на глобальное состояние приложения из кода теста. Например, для упрощения интеграционного теста путем замены части сервисов внутри приложения на моки. В приложении можно задать обработчик запросов, к которому можно обращаться через вызов driver.requestData('some param') в коде теста: Поиск виджетов при интеграционном тестировании при помощи глобального объекта find отличается составом методов от аналогичной функциональности при тестировании виджетов. Однако общий смысл практически не меняется: Мы рассмотрели способы организации тестирования интерфейса приложения, написанного с использованием Flutter. Мы можем как реализовать тесты для проверки соответствия кода требованиям технического задания, так и делать тесты этим самым заданием. Из замеченных недостатков интеграционного тестирования — нет возможности взаимодействовать с системными диалогами платформы. Но, например, можно избежать запросов пермишенов путем выдачи разрешений из командной строки на этапе установки приложения, как описано в этом тикете. Эта статья — отправная точка для изучения темы тестирования, которая в общих чертах знакомит читателя с тем, как функционирует тестирование пользовательского интерфейса. Она не избавит от чтения документации, из которой достаточно легко узнать как функционирует тот или иной класс или метод. Ведь изучения новой для себя темы требуется, в первую очередь, понимание всех происходящих процессов в целом, без излишней детализации. Это не единственная наша статья про Flutter — например, здесь можно прочитать про Dart, откуда он взялся, и чем отличается от Java и Kotlin. Программист