{"id":21748310,"url":"https://github.com/surfstudio/surf-flutter-test","last_synced_at":"2025-04-13T07:13:29.054Z","repository":{"id":71210927,"uuid":"531038812","full_name":"surfstudio/surf-flutter-test","owner":"surfstudio","description":"Tools and conveniences for better integration and widget testing on Flutter","archived":false,"fork":false,"pushed_at":"2024-01-22T14:38:32.000Z","size":2229,"stargazers_count":12,"open_issues_count":1,"forks_count":3,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-04-13T07:13:21.602Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Dart","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/surfstudio.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2022-08-31T10:41:30.000Z","updated_at":"2024-07-03T03:39:47.000Z","dependencies_parsed_at":"2024-01-22T12:54:44.061Z","dependency_job_id":null,"html_url":"https://github.com/surfstudio/surf-flutter-test","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/surfstudio%2Fsurf-flutter-test","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/surfstudio%2Fsurf-flutter-test/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/surfstudio%2Fsurf-flutter-test/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/surfstudio%2Fsurf-flutter-test/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/surfstudio","download_url":"https://codeload.github.com/surfstudio/surf-flutter-test/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248675422,"owners_count":21143768,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-11-26T08:13:00.885Z","updated_at":"2025-04-13T07:13:28.293Z","avatar_url":"https://github.com/surfstudio.png","language":"Dart","funding_links":[],"categories":[],"sub_categories":[],"readme":"Пакет в котором содержатся последние наработки Surf по Е2Е и Widget автотестам с использованием\nintegration_test, flutter_test и flutter_gherkin\n\n## Features\n\n- integration_test + flutter_test:\n  - Базовые локаторы которые будут полезны всегда в TestScreen\n  - Дополнительный Finder который позволяет менять skipOffstage свойство у существующих Finder'ов\n  - Задержки Duration которые часто используются\n  - Неявные ожидания разных видов\n  - Неявные действия использующие ожидания (тапы, свайпы, ввод текстов итд)\n  - Фикс создания отчета json при падении тестов в flutter_driver\n- flutter_gherkin:\n  - given/when/then с передачей WidgetTester в колбеке для удобства\n  - ContextualWorld который позволяет передавать данные внутри сценария\n  - Хук для создания скриншота при падении и фикса ошибки с debugDefaultTargetPlatformOverride\n  - Обработка json отчета в формат пригодный для cucumber-html-reporter\n\n## Getting started\n\nДля использования достаточно добавить пакет в dev-зависимости в pubspec.yaml таким образом:\n\n```yaml\nsurf_flutter_test:\n  git:\n    url: https://github.com/surfstudio/surf-flutter-test\n    ref: main\n```\n\n## Usage\n\nПакет можно использовать по разному, но основные варианты такие:\n\n### testerGiven/testerWhen/testerThen\n\nВ flutter_gherkin для написания тестов используются шаги, которые реализуются через given/when/then функции с колбеками\nв которых мы выполняем свой код. Для удобства в этой библиотеке были созданы кастомные варианты таких функций которые\nменяют сигнатуру колбека.\nВ них теперь кроме параметров шага и context передается tester параметр - объект класса WidgetTester через который и\nпроисходит все тестирование.\nСравнение использования (больше примеров можно увидеть в example):\n\n```dart\n// стандартная реализация\nwhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context) async {\n    final tester = context.world.rawAppDriver;\n    await tester.implicitTap(mainTestScreen.editProfileBtn);\n  },\n),\n```\n\n```dart\n// улучшенный вариант\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    await tester.implicitTap(mainTestScreen.editProfileBtn);\n  },\n),\n```\n\nТакое изменение позволяет убрать boilerplate код из почти-что каждого шага\nАналогичное поведение и для given/then шагов.\n\n### pumps\n\nВ integration и widget тестах между действиями нужно делать pump. Нужно это для того чтобы приложение работало из-за\nFakeAsync, так и для того чтобы ждать пока не случится какое то событие. Эдакий аналог sleep(duration).\nВ шагах тестов нужно регулярно чего-то ждать, что не очень удобно. Много в каких фреймворках вводится понятие\n\"неявное ожидание\", подразумевающее что мы просто описываем действия, а фреймворк сам ждет пока сможет его выполнить.\nЭту цель и пытаются выполнить pump методы.\n\nПример:\n\n```dart\n// стандартная реализация\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    await tester.pump(); // если запрос и ожидания результата, то этого мало\n    await tester.pumpAndSettle(); // если бесконечная анимация, то тест упадет\n    await tester.tap(mainTestScreen.editProfileBtn);\n  },\n),\n```\n\n```dart\n// улучшенный вариант с pump\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    await tester.pumpUntilVisibleAmount(mainTestScreen.editProfileBtn, 1);\n    await tester.tap(mainTestScreen.editProfileBtn);\n  },\n),\n```\n\n### implicitActions\n\nКроме ожиданий с виджетами еще нужно взаимодействовать. Причем зачастую после этих самых ожиданий. Поэтому, их можно\nобъединить чтобы не писать лишний код. Кроме ожиданий эти действия могут иметь новую логику для удобства.\n\n```dart\n// стандартная реализация\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    await tester.pumpUntilVisibleAmount(mainTestScreen.editProfileBtn, 1);\n    await tester.tap(mainTestScreen.editProfileBtn);\n    // пример с скроллом\n    await tester.pumpUntilVisible(mainTestScreen.scroll);\n    await tester.dragUntilVisible(mainTestScreen.listItem, mainTestScreen.scroll);\n  },\n),\n```\n\n```dart\n// улучшенный вариант\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    await tester.implicitTap(mainTestScreen.editProfileBtn);\n    // пример с скроллом\n    await tester.implicitScrollUntilVisible(mainTestScreen.listItem);\n  },\n),\n```\n\nКак видим нам нужно просто тапнуть и пролистать до виджета, и не нужно писать ожидания или задавать finder для\nscrollView (хотя в некоторых случаях стоит использовать simpleDragUntilVisible т.к. поиск scrollView может занимать\nмного времени).\n\n### Finders\n\n#### changeSkipOffstage\n\nВ тестах часто нужен Finder с возможностью посмотреть \"за экран\" с помощью skipOffstage: true параметра. Однако это поле\nу класса Finder финальное, а делать два Finder'а каждый раз когда хочется такого это избыточно. Поэтому был создан\nFinder-обертка, который позволяет поменять любому Finder'у этот параметр на лету.\n\nНаглядный пример из метода implicitScrollUntilVisible:\n\n```dart\nFuture\u003cvoid\u003e implicitScrollUntilVisible(\n  Finder finder, {\n  Offset moveStep = TestGestures.scrollDown,\n  Duration? duration,\n  int maxIteration = 50,\n  Finder? scrollFinder,\n  Finder? errorWidget,\n}) async {\n  // т.к. мы скроллим, Finder вполне себе может быть не виден на экране\n  await pumpUntilVisible(finder.changeSkipOffstage(), doThrow: false);\n  scrollFinder ??= TestScreen().scroll;\n  // для поиска скролла нам тоже нужен виджет которого скорее всего не видим\n  final scrollView = find.ancestor(of: finder.changeSkipOffstage(), matching: scrollFinder);\n  await simpleDragUntilVisible(\n    finder.hitTestable(), // а тут уже проверяем hitTestable т.к. иногда виджет видно до того как на него можно нажать\n    scrollView,\n    moveStep,\n    duration: duration,\n    maxIteration: maxIteration,\n    errorWidget: errorWidget,\n  );\n}\n```\n\n### Screens\n\nХорошим паттерном автотестов является вынос Finder'ов в отдельные классы для удобства и читаемости. Так называемый\nPage Object. Часто используемые Finder'ы были собраны в TestScreen. Кроме того, экраны фич нужно наследовать от\nэтого класса (или его потомка если нужно что-то изменить на глобальном уровне) чтобы можно было переопределять общие\nэлементы на экранах и не было нужды использовать такие общие элементы через отдельную \"базовую\" страницу.\nИзначально экраны были статическими т.к. по сути объект не несет в себе никакой ценности для тестов, но у статических\nполей нет наследования поэтому пришли к варианту с хранением в общем файле.\n\n```dart\n// реализация без экранов\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    // плохо т.к. нельзя переиспользовать одинаковый виджет в разных тестах\n    await tester.implicitTap(find.widgetWithText(ElevatedButton, 'Edit profile'));\n  },\n),\n```\n\n```dart\n// улучшенный вариант\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    // mainTestScreen хранится в библиотеке скринов, а класс содержит поле editProfileBtn = finder из прошлого примера\n    await tester.implicitTap(mainTestScreen.editProfileBtn);\n  },\n),\n```\n\n### TestDelays\n\nВо время тестирования часто нужно полагаться на объекты Duration типа. В тех же ожиданиях нужно регулировать таймауты\nитд. Для этого есть класс TestDelays, в котором содержатся базовые виды задержек. Этот класс можно переопределить на\nсвоем проекте для изменения дефолтных значений. Инстансы таких классов стоит хранить там же где и инстансы страниц.\n\n```dart\n// стандартная реализация\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    await tester.pumpUntilSettled(timeout: const Duration(seconds:2)); // не получается переиспользовать\n    await tester.tap(mainTestScreen.editProfileBtn);\n  },\n),\n```\n\n```dart\n// улучшенная реализация\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    // testDelays = TestDelays(); в библиотеке экранов\n    await tester.pumpUntilSettled(timeout: testDelays.interactionDelay);\n    await tester.tap(mainTestScreen.editProfileBtn);\n  },\n),\n```\n\n### TestGestures\n\nПо аналогии с Duration, в тестах нужно использоваться объекты Offset, для различных свайпов итд. Хранить их стоит в\nфайлах страниц, т.к. они абстрактные классы с статичными полями\n\n```dart\n// стандартная реализация\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    // нельзя переиспользовать + не особо интуитивно что происходит\n    await tester.impicitScrollUntilVisible(finder, moveStep: const Offset(0, -120));\n  },\n),\n```\n\n```dart\n// улучшенная реализация\ntesterWhen\u003cContextualWorld\u003e(\n  RegExp(r'Я перехожу к редактированию профиля$'),\n  (context, tester) async {\n    // переиспользуемый Offset + понятно в какую сторону будет скролл\n    await tester.impicitScrollUntilVisible(finder, moveStep: TestGestures.scrollDown);\n  },\n),\n```\n\n### ContextualWorld\n\nИногда в сценариях бывает необходимость передать данные, например для проверок вроде \"проверить что значение на\nследующем экране такое же как и на прошлом\". Кроме того часто в приложениях встречается авторизация. Чтобы не указывать\nв сценариях учетные данные каждый раз, в ContextualWorld можно передать *профиль* - объект Map который соотносит\n*смысловое название* учетки с конкретными данными для авторизации. Это нужно для того, чтобы можно было простым конфигом\nпоменять сервер и не трогать gherkin сценарии.\n\nПример:\n\n```dart\n// создаем вариант юзера под нужды проекта\nclass UserImpl extends User {\n  final String login;\n  final String password;\n  final String pin;\n  final String otp;\n\n  UserImpl(this.login, this.password, this.pin, this.otp);\n}\n\n// создаем класс учеток для переиспользования\nabstract class DevUsers {\n  static UserImpl test = UserImpl('test', '1234', '1111', '1111');\n}\n\n// создаем профиль\nfinal devCredentials = \u003cString, UserImpl\u003e{\n  'test_acc': DevUsers.test,\n};\n\n// в gherkin_suite_test создаем World и передаем в него профиль\ncreateWorld: (config) =\u003e Future.value(ContextualWorld(devCredentials))\n\ntesterGiven1\u003cString, ContextualWorld\u003e(\n  RegExp(r'Я использую аккаунт {string}$'),\n  (userType, context, tester) async {\n    context.world.setUser(userType); // сохраняем юзера в given шаге чтобы использовать в других шагах\n  },\n),\ntesterWhen\u003cContextualWorld\u003cUserImpl\u003e\u003e( // указываем тип юзера чтобы получить доступ к полям\n  RegExp(r'Я авторизуюсь по логину$'),\n  (context, tester) async {\n    final user = context.world.user; // юзер уже сохранен\n    await tester.implicitEnterText(authTestScreen.loginField, user.login); // вводим логин\n    await tester.implicitTap(authTestScreen.loginBtn);\n  },\n),\ntesterWhen1\u003cString, ContextualWorld\u003e(\n  RegExp(r'Я указываю дату рождения {string}$'),\n  (birthdate, context, tester) async {\n    final finder = profileTestScreen.birthdayField;\n    await tester.pumpUntilVisible(finder);\n    tester.widget\u003cTextField\u003e(finder).controller?.text = birthdate;\n    await tester.pump();\n    // параметры хранятся в файлах с страницей фичи в виде абстрактного класса с статическими полями\n    context.world.setContext(ProfileTestParams.birthdate, birthdate); // запоминаем ДР\n  },\n),\ntesterThen\u003cContextualWorld\u003e(\n  RegExp(r'Я вижу заполненное поле даты рождения$'),\n  (context, tester) async {\n    await tester.pumpUntilVisible(profileTestScreen.birthdayField);\n    final birthdate = tester.widget\u003cTextField\u003e(profileTestScreen.birthdayField).controller?.text;\n    expect(birthdate, context.world.getContext\u003cString\u003e(ProfileTestParams.birthdate)); // достаем данные из контекста\n  },\n),\n```\n\nКак видим, для авторизации нам достаточно в сценарии один раз указать тип аккаунта, вроде 'test_acc', и в точке входа\nуказать профиль в конфиге. Дальше можно в шагах получать сущность пользователя и использовать данные по своему\nусмотрению. С передачей данных вроде тоже все понятно.\n\n### Hooks\n\nВ flutter_gherkin возможно использование хуков - блоков кода которые выполняются после (или перед) определенными\nсобытиями, например после завершения теста. В этой библиотеке есть полезный хук - ConvenienceHook. Его функции такие:\n\n1. При ошибке шага, делать дополнительный pumpUntilSettled и делать скриншот с помощью рендера. Так скриншоты становятся\n   стабильными на всех платформах и нет проблем во время попытки сделать скриншот.\n2. После завершения сценария сбрасывается debugDefaultTargetPlatformOverride т.к. на нее часто жалуются assert'ы в\n   flutter_test библиотеке\n\nДобавляется этот хук также как и другие:\n\n```dart\n// gherkin_suite_test.dart конфиг\nhooks: [\n  ConvenienceHook(),\n],\n```\n\n### flutter_driver utils\n\nКроме фич которые помогают непосредственно с integration_test + flutter_gherkin, есть еще полезные вещи при работе с\nчастью flutter_driver.\n\n#### Increase Android device wake time above limit\n\nПосле определенного количества тестов оин начинают длиться дольше 30 минут которые можно выставить в настройках андроида\nдля засыпания. Плюс для автоматизации увеличения этого времени есть функция androidScreenDuration, которую нужно\nвызывать в flutter_driver сегменте тестов, т.к. только этот код выполняется на хосте. Функция сама по себе ищет\nподключенные устройства и всем им выставляет настройку.\n\n#### fixedIntegrationDriver\n\nОсновная цель использования flutter_driver в е2е тестах - получение отчета, т.к. тесты выполняются на устройстве.\nFlutter driver тут выступает в роли \"получить ответ от integration_test\".\nВ инициализации integrationDriver есть проблема - если тесты падают с ошибками, то колбек с обработкой отчета не\nвызывается, т.е. отчет мы получим только если все тесты пройдут успешно, что не очень удобно.\nПоэтому в данном пакете добавлен fixedIntegrationDriver, который исправляет эту ошибку, а так же использует\nwriteGherkinReports колбек по умолчанию который парсит отчет в cucumber.json формат.\n\nПример main функции flutter_driver с доработками\n\n```dart\nFuture\u003cvoid\u003e main() async {\n  integration_test_driver.testOutputsDirectory = 'integration_test/gherkin/reports';\n\n  await androidScreenDuration();\n\n  return fixedIntegrationDriver(\n    timeout: const Duration(minutes: 120), // таймаут на все тесты, должен быть больше чем общее время прогона\n  );\n}\n\n\n```\n\n### Extensions\n\nКроме всего прочего, в проекте есть StringExtension который содержит метод cleanEllipsisOverflow.\n\nПример:\n```dart\nFlexible(\n  child: Text(\n    title.overflow, // добавляются символы\n    key: ProductTestKeys.productId(product.accountId ?? 0),\n    style: (product is Card ? StyleRes.regular14White : StyleRes.regular14)\n        .copyWith(height: cardTextHeight),\n    maxLines: 1,\n    overflow: TextOverflow.ellipsis,\n  ),\n),\n\ntesterThen\u003cContextualWorld\u003e(\n  RegExp(r'Я вижу новое имя продукта в деталке$'),\n  (context, tester) async {\n    final id = context.world.getContext\u003cint\u003e(ProductTestParams.renamedProduct);\n    final newName = context.world.getContext\u003cString\u003e(ProductTestParams.newName);\n    await tester.pumpUntilVisible(ProductTestScreen.productNameId(id));\n    final actualNameWidget = tester.firstWidget\u003cText\u003e(ProductTestScreen.productNameId(id));\n    final actualName = actualNameWidget.data?.cleanOverflow; // очищаем символы\n    expect(actualName, newName); // сравнение работает\n  },\n),\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsurfstudio%2Fsurf-flutter-test","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsurfstudio%2Fsurf-flutter-test","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsurfstudio%2Fsurf-flutter-test/lists"}