Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Перевод материала подготовлен в рамках онлайн-курса "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».