Перевод материала подготовлен в рамках онлайн-курса "Flutter Mobile Developer".
Приглашаем также всех желающих на бесплатный двухдневный интенсив «Создаем приложение на Flutter для Web, iOS и Android». На интенсиве узнаем, как именно Flutter позволяет создавать приложения для Web-платформы, и почему теперь это стабильный функционал; как именно работает Web-сборка. Напишем приложение с работой по сети. Подробности и регистрация здесь.

Это продолжение первой части статьи о тестировании виджетов во Flutter.
Продолжим наше изучение процесса тестирования виджетов.
В прошлый раз мы сосредоточились на базовой структуре тестового файла и подробно рассмотрели, что может делать функция testWidgets() в тесте. Хотя эта функция отвечает за выполнение теста, непосредственно к тесту мы не перешли и даже не посмотрели, как он выглядит, — и это было сделано специально. На мой взгляд, хорошее знание компонентов, из которых состоит тест, может принести огромную пользу в момент их написания.
Небольшое резюме предыдущей части статьи:
Тесты виджетов предназначены для тестирования небольших компонентов приложения.
Мы сохраняем наши тесты в папке test.
Внутри функции
testWidgets()пишем тесты виджетов, и мы подробно рассмотрели состав этой функции.
Продолжим наш анализ.
Как пишется тест виджета?
Тест виджета обычно дает возможность проверить:
Отображаются ли визуальные элементы.
Дает ли взаимодействие с визуальными элементами правильный результат.
Начнем со второй задачи, а первая подтянется сама собой как производная. Для этого мы обычно выполняем следующие шаги в ходе тестирования:
Задаем начальные условия и создаем виджет для тестирования.
Находим визуальные элементы на экране с помощью какого-либо свойства (например, ключа).
Взаимодействуем с элементами (например, кнопкой), используя тот же самый идентификатор.
Убеждаемся, что результаты соответствуют ожидаемым.
Создание виджета для тестирования
Чтобы протестировать виджет, очевидно, нам нужен сам виджет. Давайте рассмотрим тест по умолчанию в папке test:
void main() { testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here }, ); }
Наверняка, вы заметили объект WidgetTester в функции обратного вызова, где мы пишем наш тест. Пришло время применить его.
Чтобы создать новый виджет для тестирования, используем метод pumpWidget():
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(), ), ), ); }, );
(Не забудьте про await, иначе тест будет выдавать кучу ошибок.)
Этот метод создает виджет для тестирования.
Более подробно о WidgetTester мы поговорим чуть позже, сначала нам нужно разобраться с другим вопросом.
Объекты-искатели
Должен признаться, что в процессе написания этой статьи понятие «поиск» у меня вызвало стойкое ощущение жамевю, которое не удалось полностью стряхнуть и по сей момент.
Если на первом шаге мы создаем экземпляр виджета для тестирования, то второй шаг заключается в поиске визуального элемента, с которым мы хотим взаимодействовать, — это может быть кнопка, текст и т. д.
Итак, как же найти виджет? Для этого мы используем объект-искатель, класс Finder. (Вы можете искать и элементы, но это другая тема.)
На словах просто, но на деле вам нужно определить что-то уникальное для виджета — тип, текст, потомков или предков и т. д.
Давайте рассмотрим широко распространенные и некоторые более специфические способы поиска виджетов:
find.byType()
Давайте в качестве примера рассмотрим поиск виджета Text:
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(), body: Center( child: Text('Hi there!'), ), ), ), ); var finder = find.byType(Text); }, );
Здесь для создания объекта-искателя мы используем предопределенный экземпляр класса CommonFinders под именем find. Функция byType() помогает нам найти ЛЮБОЙ виджет определенного типа. Таким образом, если в дереве виджетов существует два текстовых виджета, будут идентифицированы ОБА. Поэтому, если вы хотите найти определенный виджет Text, подумайте о том, чтобы добавить в него ключ или использовать следующий тип:
find.text()
Чтобы найти конкретный виджет Text, используйте функцию find.text():
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(), body: Center( child: Text('Hi there!'), ), ), ), ); var finder = find.text('Hi there!'); }, );
Это также применимо и для любого виджета типа EditableText, например виджета TextField.
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here var controller = TextEditingController.fromValue(TextEditingValue(text: 'Hi there!')); await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(), body: Center( child: TextField(controller: controller,), ), ), ), ); var finder = find.text('Hi there!'); }, );
find.byKey()
Один из самых распространенных и простых способов найти виджет — это просто добавить в него ключ:
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(), body: Center( child: Icon( Icons.add, key: Key('demoKey'), ), ), ), ), ); var finder = find.byKey(Key('demoKey')); }, );
find.descendant() и find.ancestor()
Это более специфический тип, с помощью которого можно найти потомка или предка виджета, отвечающего определенным свойствам (для чего мы снова используем объект-искатель).
Скажем, мы хотим найти значок, который является потомком виджета Center, имеющего ключ. Мы можем сделать это следующим образом:
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(), body: Center( key: Key('demoKey'), child: Icon(Icons.add), ), ), ), ); var finder = find.descendant( of: find.byKey(Key('demoKey')), matching: find.byType(Icon), ); }, );
Здесь мы указываем, что искомый виджет является потомком виджета Center (для этого используется параметр of) и отвечает свойствам, которые мы снова задаем с помощью объекта-искателя.
Вызов find.ancestor() во многом схож, но роли мен��ются местами, так как мы пытаемся найти виджет, расположенный выше виджета, определенного с помощью параметра of.
Если бы здесь мы пытались найти виджет Center, мы бы сделали следующее:
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(), body: Center( key: Key('demoKey'), child: Icon(Icons.add), ), ), ), ); var finder = find.ancestor( of: find.byType(Icon), matching: find.byKey(Key('demoKey')), ); }, );
Создание пользовательского объекта-искателя
При использовании функций вида find.xxxx() мы используем предопределенный класс Finder. А если мы хотим использовать собственный способ поиска виджета?
Продолжая череду неудачных примеров, предположим, что нам нужен объект-искатель, который находит все значки, не имеющие ключей. Назовем этот объект BadlyWrittenWidgetFinder.
Сначала дополним класс
MatchFinder.
class BadlyWrittenWidgetFinder extends MatchFinder { @override // TODO: implement description String get description => throw UnimplementedError(); @override bool matches(Element candidate) { // TODO: implement matches throw UnimplementedError(); } }
2. С помощью функции matches() мы проверяем, соответствует ли виджет нашим условиям. В нашем случае предстоит проверить, является ли виджет значком и равен ли его ключ значению null:
class BadlyWrittenWidgetFinder extends MatchFinder { BadlyWrittenWidgetFinder({bool skipOffstage = true}) : super(skipOffstage: skipOffstage); @override String get description => 'Finds icons with no key'; @override bool matches(Element candidate) { final Widget widget = candidate.widget; return widget is Icon && widget.key == null; } }
3. Пользуясь преимуществами расширений, мы можем добавить этот объект-искатель непосредственно в класс CommonFinders (объект find является экземпляром этого класса):
extension BadlyWrittenWidget on CommonFinders { Finder byBadlyWrittenWidget({bool skipOffstage = true }) => BadlyWrittenWidgetFinder(skipOffstage: skipOffstage); }
4. Благодаря расширениям мы можем обращаться к объекту-искателю так же, как и к любым другим объектам:
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget( MaterialApp( home: Scaffold( appBar: AppBar(), body: Center( key: Key('demoKey'), child: Icon(Icons.add), ), ), ), ); var finder = find.byBadlyWrittenWidget(); }, );
Теперь, когда мы познакомились с объектами-искателями, перейдем к изучению класса WidgetTester.
Все, что нужно знать о WidgetTester
Это достаточно большая тема, заслуживающая отдельной статьи, но мы здесь постараемся рассмотреть основные моменты.
Класс WidgetTester позволяет нам взаимодействовать с тестовой средой. Тесты виджетов выполняются не совсем так, как они выполнялись бы на реальном устройстве, поскольку асинхронное поведение в тесте имитируется. Следует отметить и другое отличие:
В тесте виджета функция setState() работает не так, как она обычно работает.
Хотя функция setState() помечает виджет, подлежащий перестраиванию, в реальности она не перестраивает дерево виджетов в тесте. Так как же нам это сделать? Давайте посмотрим на методы pump.
Для чего нужны методы pump
Вкратце:
pump()инициирует новый кадр (перестраивает виджет),pumpWidget()устанавливает корневой видже�� и затем инициирует новый кадр, аpumpAndSettle()вызывает функциюpump()до тех пор, пока виджет не перестанет запрашивать новые кадры (обычно при запущенной анимации).
Немного о функции pumpWidget()
Как мы видели ранее, функция pumpWidget() использовалась для установки корневого виджета для тестирования. Она вызывает функцию runApp(), используя указанный виджет, и осуществляет внутренний вызов функции pump(). При повторном вызове функция перестраивает все дерево.
Подробнее о функции pump()
Мы должны вызвать функцию pump(), чтобы на самом деле перестроить нужные нам виджеты. Допустим, у нас есть стандартный виджет счетчика такого вида:
class CounterWidget extends StatefulWidget { @override _CounterWidgetState createState() => _CounterWidgetState(); } class _CounterWidgetState extends State<CounterWidget> { var count = 0; @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Text('$count'), floatingActionButton: FloatingActionButton( child: Icon(Icons.add), onPressed: () { setState(() { count++; }); }, ), ), ); } }
Виджет просто хранит значение счетчика и обновляет его при нажатии кнопки FloatingActionButton, как в стандартном приложении-счетчике.
Давайте попробуем протестировать виджет: найдем значок добавления и нажмем его, чтобы проверить, станет ли значение счетчика равным 1:
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget(CounterWidget()); var finder = find.byIcon(Icons.add); await tester.tap(finder); // Ignore this line for now // It just verifies that the value is what we expect it to be expect(find.text('1'), findsOneWidget); }, );
А вот и нет:

Причина в том, что мы перестраиваем виджет Text, отображающий счетчик, с помощью функции setState() в виджете, но в данном случае виджет не перестраивается. Нам также необходимо вызвать метод pump():
testWidgets( 'Test description', (WidgetTester tester) async { // Write your test here await tester.pumpWidget(CounterWidget()); var finder = find.byIcon(Icons.add); await tester.tap(finder); await tester.pump(); // Ignore this line for now // It just verifies that the value is what we expect it to be expect(find.text('1'), findsOneWidget); }, );
И мы получаем более приятный результат:

Если вам нужно запланировать отображение кадра через определенное время, в метод pump() также можно передать время — тогда будет запланировано перестраивание виджета ПОСЛЕ истечения указанного временного промежутка:
await tester.pump(Duration(seconds: 1));
Обратите внимание, что в тесте на самом деле не будет осуществляться ожидание в течение указанного времени, вместо этого счетчик времени будет сдвинут вперед на это время.
У метода pump есть полезная особенность: вы можете остановить его на нужном этапе перестраивания и визуализации виджета. Для этого необходимо задать параметр EnginePhase данного метода:
enum EnginePhase { /// The build phase in the widgets library. See [BuildOwner.buildScope]. build, /// The layout phase in the rendering library. See [PipelineOwner.flushLayout]. layout, /// The compositing bits update phase in the rendering library. See /// [PipelineOwner.flushCompositingBits]. compositingBits, /// The paint phase in the rendering library. See [PipelineOwner.flushPaint]. paint, /// The compositing phase in the rendering library. See /// [RenderView.compositeFrame]. This is the phase in which data is sent to /// the GPU. If semantics are not enabled, then this is the last phase. composite, /// The semantics building phase in the rendering library. See /// [PipelineOwner.flushSemantics]. flushSemantics, /// The final phase in the rendering library, wherein semantics information is /// sent to the embedder. See [SemanticsOwner.sendSemanticsUpdate]. sendSemanticsUpdate, } await tester.pump(Duration.zero, EnginePhase.paint);
Примечание. Я применил перечисление в исходном коде, только чтобы нагляднее изобразить этапы. Не добавляйте его в свой код.
Переходим к pumpAndSettle()
Метод pumpAndSettle() — это, по сути, тот же метод pump, но вызываемый до того момента, когда не будет запланировано ни одного нового кадра. Он помогает завершить все анимации.
Он имеет аналогичные параметры (время и этап), а также дополнительный параметр — тайм-аут, ограничивающий время вызова данного метода.
await tester.pumpAndSettle( Duration(milliseconds: 10), EnginePhase.paint, Duration(minutes: 1), );
Взаимодействие со средой
Класс WidgetTester позволяет нам использовать сложные взаимодействия помимо обычных взаимодействий типа «поиск + касание». Вот что можно делать с его помощью:
Метод tester.drag() позволяет инициировать перетаскивание из середины виджета, который мы находим с помощью объекта-искателя по определенному смещению. Мы можем задать направление перетаскивания, указав соответствующие смещения по осям X и Y:
var finder = find.byIcon(Icons.add); var moveBy = Offset(100, 100); var slopeX = 1.0; var slopeY = 1.0; await tester.drag(finder, moveBy, touchSlopX: slopeX, touchSlopY: slopeY);
Мы также можем инициировать перетаскивание с контролем по времени, используя метод tester.timedDrag():
var finder = find.byIcon(Icons.add); var moveBy = Offset(100, 100); var dragDuration = Duration(seconds: 1); await tester.timedDrag(finder, moveBy, dragDuration);
Чтобы просто перетащить объект из одной позиции на экране в другую, не прибегая к объектам-искателям, используйте метод tester.dragFrom(), который позволяет инициировать перетаскивание из нужной позиции на экране.
var dragFrom = Offset(250, 300); var moveBy = Offset(100, 100); var slopeX = 1.0; var slopeY = 1.0; await tester.dragFrom(dragFrom, moveBy, touchSlopX: slopeX, touchSlopY: slopeY);
Также существует вариант этого метода с контролем по времени — tester.timedDragFrom().
var dragFrom = Offset(250, 300); var moveBy = Offset(100, 100); var duration = Duration(seconds: 1); await tester.timedDragFrom(dragFrom, moveBy, duration);
Примечание. Если вы хотите имитировать смахивание, используйте метод
tester.fling()вместоtester.drag().
Создание пользовательских жестов
Давайте попробуем создать собственный жест: касание определенной позиции и «рисование» прямоугольника на экране с возвратом в исходную позицию.
Сначала нам нужно инициализировать жест:
var dragFrom = Offset(250, 300); var gesture = await tester.startGesture(dragFrom);
Первый параметр определяет, где происходит начальное касание экрана.
Затем мы можем использовать следующий код для создания собственного жеста:
var dragFrom = Offset(250, 300); var gesture = await tester.startGesture(dragFrom); await gesture.moveBy(Offset(50.0, 0)); await gesture.moveBy(Offset(0.0, -50.0)); await gesture.moveBy(Offset(-50.0, 0)); await gesture.moveBy(Offset(0.0, 50.0)); await gesture.up();
При тестировании доступны и другие возможности, такие как получение позиций используемых виджетов, взаимодействие с клавиатурой и прочее. Это, как правило, тривиальные вещи, и я, возможно, расскажу о них в одной из следующих статей (когда я это пишу, на часах уже пять утра — может, мое нежелание вдаваться в детали с этим как-то связано, кто знает).
В этой части статьи мы узнали об особенностях работы с классами Finder и WidgetTester. Далее мы завершим наше знакомство с процессом тестирования виджетов и изучим дополнительные варианты тестирования — это будет в третьей части статьи.
Подробнее о курсе "Flutter Mobile Developer".
Участвовать в интенсиве «Создаем приложение на Flutter для Web, iOS и Android».
