Углубленный анализ тестирования виджетов во Flutter. Часть II. Классы Finder и WidgetTester

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Перевод материала подготовлен в рамках онлайн-курса "Flutter Mobile Developer".

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


Это продолжение первой части статьи о тестировании виджетов во Flutter.

Продолжим наше изучение процесса тестирования виджетов. 

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

Небольшое резюме предыдущей части статьи:

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

  2. Мы сохраняем наши тесты в папке test.

  3. Внутри функции testWidgets() пишем тесты виджетов, и мы подробно рассмотрели состав этой функции.

Продолжим наш анализ.

Как пишется тест виджета?

Тест виджета обычно дает возможность проверить:

  1. Отображаются ли визуальные элементы.

  2. Дает ли взаимодействие с визуальными элементами правильный результат.

Начнем со второй задачи, а первая подтянется сама собой как производная. Для этого мы обычно выполняем следующие шаги в ходе тестирования:

  1. Задаем начальные условия и создаем виджет для тестирования.

  2. Находим визуальные элементы на экране с помощью какого-либо свойства (например, ключа).

  3. Взаимодействуем с элементами (например, кнопкой), используя тот же самый идентификатор.

  4. Убеждаемся, что результаты соответствуют ожидаемым.

Создание виджета для тестирования

Чтобы протестировать виджет, очевидно, нам нужен сам виджет. Давайте рассмотрим тест по умолчанию в папке 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.

  1. Сначала дополним класс 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».

Источник: https://habr.com/ru/company/otus/blog/556760/


Интересные статьи

Интересные статьи

Что выбрать для анимирования элементов веб-страниц? JavaScript или CSS? Этот вопрос однажды вынужден будет задать себе каждый веб-разработчик. А может — и не однажды. JavaScript-пр...
Первая часть: Основы работы с видео и изображениями Что? Видеокодек — это часть программного/аппаратного обеспечения, сжимающая и/или распаковывающая цифровое видео. Для чего? Невзирая ...
Intel выпустил свой самый быстрый потребительский процессор для настольных ПК: Core i9-9900KS, у которого все восемь ядер работают на частоте 5,0 ГГц. Вокруг нового процессора много шума, но ...
Игры начинают становиться всё менее деревянными, в некоторых случаях начинает проявляться оказуаливание — упор на графику и упрощение игрового процесса в сравнении с предыдущими играми этого жанр...
Материал, первую часть перевода которого мы сегодня публикуем, посвящён новым стандартным возможностям JavaScript, о которых шла речь на конференции Google I/O 2019. В частности, здесь мы поговор...