patrol-test-architecture

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Order of actions when writing new tests

编写新测试的操作顺序

  1. Read the key files directly (modules aggregator, test wrapper, main keys.dart, and the specific app feature files for the scenario). These are known paths — don't search for them, open them directly
  2. Inspect existing modules for functions that can be reused
  3. Also think if one of existing functions can be adjusted to match its existing usage and new test
  4. Assign test keys to required elements if they are not assigned yet
  5. Start writing test: reuse existing modules/functions + put new test steps in new test file
  6. Write patrol actions directly in the test file, do not create new methods in modules
  7. Run the test frequently during development — don't wait until the full test is written. Run after completing each logical group of steps to catch failures early
  8. After full test passes, reorganize new code into existing/new modules. This is mandatory — the test file must only call module methods, not use Patrol APIs directly
  9. Rerun the test after reorganizing to confirm it still passes
  1. 直接读取关键文件(模块聚合器、测试包装器、主keys.dart文件,以及对应场景的特定应用功能文件)。这些是已知路径——无需搜索,直接打开即可
  2. 检查现有模块,寻找可复用的函数
  3. 同时思考是否可以调整某个现有函数,使其适配现有用法和新测试需求
  4. 若所需元素尚未分配测试键,为其分配测试键
  5. 开始编写测试:复用现有模块/函数 + 将新测试步骤写入新测试文件
  6. 直接在测试文件中编写Patrol操作,不要在模块中创建新方法
  7. 在开发过程中频繁运行测试——不要等到完整测试编写完成后再运行。完成每组逻辑步骤后就运行一次,尽早发现失败问题
  8. 完整测试通过后,将新代码重组到现有/新模块中。这是强制要求——测试文件必须仅调用模块方法,不得直接使用Patrol API
  9. 重组后重新运行测试,确认测试仍能通过

Patrol MCP Usage

Patrol MCP 使用方法

When working with Patrol tests:
  • Use
    patrol-run({ "testFile": "patrol_test/your_test.dart" })
    to run tests and wait for completion
  • If no session running: starts new session with specified test file
  • If session already running: automatically restarts current tests
  • Use
    patrol-screenshot({ "platform": "android" })
    or
    patrol-screenshot({ "platform": "ios" })
    to capture screenshots for debugging test failures
  • Use
    patrol-quit({})
    to quit the session gracefully
  • Use
    patrol-status({})
    to check current status and recent output
  • Use
    patrol-native-tree({})
    to fetch the current native UI tree hierarchy for writing native interactions and interactions with apps other than the app under test.
使用Patrol测试时:
  • 使用
    patrol-run({ "testFile": "patrol_test/your_test.dart" })
    运行测试并等待完成
  • 若没有会话在运行:启动包含指定测试文件的新会话
  • 若已有会话在运行:自动重启当前测试
  • 使用
    patrol-screenshot({ "platform": "android" })
    patrol-screenshot({ "platform": "ios" })
    捕获截图,用于调试测试失败问题
  • 使用
    patrol-quit({})
    优雅退出会话
  • 使用
    patrol-status({})
    检查当前状态和最近输出
  • 使用
    patrol-native-tree({})
    获取当前原生UI树层级,用于编写原生交互以及与被测应用之外的其他应用的交互逻辑

Patrol Test Architecture

Patrol测试架构

Structure

结构

  • Use custom
    testApp
    wrapper for all tests
  • Each test file should contain only ONE test
  • Organize tests using
    Modules
    ,
    System
    , and
    ApiClients
    pattern
  • Each module represents a feature from user perspective (eg. Auth, Home screen, Downloads, Player)
  • System is a class for native interactions, specifically for actions that we need to perform by using $.platform that are not part of our app, eg. enabling airplane mode to test offline mode. Do not put methods here unless they don't fit any module of the app
  • ApiClients - a class aggregating api clients for api communication. There will be separate clients for different apis we need to communicate with - eg. email server for testing, our test backend, or some third-party services
  • 所有测试使用自定义的
    testApp
    包装器
  • 每个测试文件应仅包含一个测试
  • 使用
    Modules
    System
    ApiClients
    模式组织测试
  • 每个模块代表用户视角下的一个功能(例如:Auth、首页、下载、播放器)
  • System是用于原生交互的类,专门处理需要通过$.platform执行的、不属于本应用的操作,例如:开启飞行模式以测试离线模式。只有当操作不适合放入应用的任何模块时,才将方法放在此处
  • ApiClients——聚合API客户端用于API通信的类。针对需要通信的不同API会有单独的客户端——例如:用于测试的邮件服务器、测试后端或某些第三方服务

Method organization in modules

模块中的方法组织

  • Do not write comments in all methods, use descriptive names instead
  • Split long methods in modules when the method is hard to read and navigate due to its length and it represents multiple logical steps that can be descriptively named
  • If there is a series of steps which is reused in many tests as a whole (e.g., long onboarding flow) create a wrapper method in the module which will call private methods for each step
  • 不要为所有方法添加注释,改用描述性命名替代
  • 当模块中的方法因过长导致难以阅读和导航,且包含多个可单独命名的逻辑步骤时,拆分该长方法
  • 如果有一系列步骤作为整体在多个测试中复用(例如:冗长的引导流程),在模块中创建一个包装方法,调用每个步骤对应的私有方法

Patrol Tests Rules

Patrol测试规则

Patrol API

Patrol API

  • Any file that directly uses Patrol APIs (
    $()
    ,
    .scrollTo()
    ,
    .tap()
    ,
    .enterText()
    ,
    .waitUntilVisible()
    , etc.) should
    import 'package:patrol/patrol.dart';
  • ALWAYS inspect Patrol API before implementing test actions:
    • Search codebase for existing Patrol API usage patterns
    • Check $.platform APIs for the specific action
    • If method not found in codebase, check: https://patrol.leancode.co/
    • Only implement after confirming the correct API method
  • ALWAYS inspect
    $.platform
    methods before implementing test actions
  • Don't use flutter_test package. Use only patrol api
  • Only run tests with Patrol MCP server for single test and
    patrol test
    for all tests. Never use
    flutter test
    command
  • Do not write patrolSetUp and patrolTearDown methods on your own
  • 任何直接使用Patrol API(
    $()
    .scrollTo()
    .tap()
    .enterText()
    .waitUntilVisible()
    等)的文件都应
    import 'package:patrol/patrol.dart';
  • 实现测试操作前,务必先查看Patrol API:
    • 在代码库中搜索现有Patrol API的使用模式
    • 查看$.platform中针对该操作的API
    • 如果在代码库中未找到对应方法,查看:https://patrol.leancode.co/
    • 确认正确的API方法后再实现
  • 实现测试操作前,务必先查看
    $.platform
    方法
  • 不要使用flutter_test包,仅使用Patrol API
  • 仅通过Patrol MCP服务器运行单个测试,使用
    patrol test
    运行所有测试。绝不要使用
    flutter test
    命令
  • 不要自行编写patrolSetUp和patrolTearDown方法

Action Rules

操作规则

  • Don't use
    $.pump
    ,
    waitUntilVisible
    or
    waitUntilExists
    and other wait methods after or before tap, scrollTo and enterText. Patrol handles it automatically. Do this only at the end of the test
  • To find widgets, use only keys
  • Don't write try catch blocks unless absolutely necessary
  • After writing test check if it works by running it with MCP server, fix it if it fails
  • If test fails because element was not found, check if it need to be scrolled to in the app code and adjust the test if needed
  • 在tap、scrollTo和enterText操作之后或之前,不要使用
    $.pump
    waitUntilVisible
    waitUntilExists
    等等待方法。Patrol会自动处理这些逻辑。仅在测试末尾使用这些方法
  • 仅使用键来查找组件
  • 除非绝对必要,否则不要编写try catch块
  • 编写完测试后,通过MCP服务器运行测试以检查是否可用,若失败则修复
  • 如果测试因未找到元素而失败,检查应用代码中是否需要滚动到该元素,并按需调整测试

Assertion Rules

断言规则

  • Don't write assertions after actions, but do write them at the end of the test
  • Prefer using
    waitUntilVisible
    as assertion at the end of the test
  • Use
    expect()
    for assertions only when
    waitUntilVisible
    is not enough
  • 不要在操作后编写断言,但要在测试末尾编写断言
  • 优先使用
    waitUntilVisible
    作为测试末尾的断言
  • 仅当
    waitUntilVisible
    无法满足需求时,才使用
    expect()
    进行断言

Native Dialog Handling

原生对话框处理

  • ALWAYS handle native dialogs that appear during the flow:
    • Handle dialogs immediately after the action that triggers them
    • For native permissions prefer
      $.platform.mobile.grantPermissionWhenInUse
      over
      $.platform.mobile.tap
  • 务必处理流程中出现的原生对话框:
    • 在触发对话框的操作后立即处理对话框
    • 对于原生权限,优先使用
      $.platform.mobile.grantPermissionWhenInUse
      而非
      $.platform.mobile.tap

Test Keys

测试键

  • Assign a key ONLY to widgets involved in testing
  • ONLY add the
    key
    parameter to existing widgets
  • Create new keys.dart files if needed
  • Always have a maximum of one keys.dart file per feature directory, but the file can contain multiple classes
  • Add imports for keys.dart files
  • Update main keys.dart aggregator
  • NEVER change widget signatures
  • NEVER refactor existing code structure
  • NEVER hardcode keys in the app, you must use keys from the keys.dart file
  • NEVER create new widgets in the app
  • NEVER create a key that is not assigned to a widget
  • Always make sure that each key value is unique
  • ALWAYS create keys.dart to store keys for the feature/widget they belong to, NOT in the main lib directory
  • ALWAYS sort keys alphabetically
  • The main
    Keys
    class in
    lib/keys.dart
    should only import and aggregate keys from feature-specific keys.dart files
  • ALWAYS assign keys using this exact pattern:
    key: keys.feature.widgetName
    (unless you are using parameterized keys)
  • Add keys as first parameter to the widget constructor
  • Group related keys in a class named after the screen (e.g.
    HomeKeys
    ). Use a private subclass of
    ValueKey<String>
    to prefix all key values with the page or widget name
  • For common widgets store them in WidgetKeys class. File containing this class should be placed in the directory that the common widget is defined
  • For widgets located in separate package (e.g. widgetbook) follow this pattern: in
    widgetbook
    directory create
    keys.dart
    file with
    WidgetKeys
    class. Those keys are assigned with
    widgetKeys.tile
    . Then, in keys.dart lib/ add it to the list of pages with
    final widgetKeys = ds.widgetKeys;
    (ds being the imported package, e.g.
    import 'package:common_ui/widgets/keys.dart' as ds;
    )
  • If widget is not unique (for example generated from a list) use a parameterized key
    • ALWAYS prefer using enums or DTOs as the parameter if they already exist
    • Use existing widget properties for parameterized keys
    • NEVER assign a parameterized key in the app and then use fixed values for it in the keys file (and vice versa)
    • NEVER create helper methods, use parameterized keys instead
    • When widgets are generated from existing enums or DTOs, always use parameterized keys with the those enum/DTO values as the parameter
    Use individual keys when:
    • Widgets are hardcoded and known at compile time
    • Widgets have distinct, meaningful names
    Use parameterized keys when:
    • Widgets are generated from dynamic data
    • Widgets are generated from a DTO or enums
    • Number of widgets is variable or large
    • Widgets are generated in loops or from lists
Steps to assign a key to a widget:
  1. Identify widget that is needed for testing
  2. Create key assignment:
    key: keys.feature.widgetName
  3. Define key in keys.dart file
  4. Verify key is assigned to widget
File Structure Examples:
Feature-specific keys:
lib/features/home/keys.dart
lib/features/profile/keys.dart
lib/features/auth/keys.dart
lib/common/widgets/keys.dart
Main keys aggregator:
lib/keys.dart (imports and aggregates all feature keys)
Examples
Feature-specific keys.dart:
dart
// lib/features/home/keys.dart
import 'package:flutter/widgets.dart';

class _HomeKey extends ValueKey<String> {
  const _HomeKey(String value) : super('home_$value');
}
class HomeKeys {
  final menuIconButton = const _HomeKey('menuIconButton');
  _HomeKey navbarItem(String label) => _HomeKey('navbarItem_$label');
}
Main keys aggregator:
dart
// lib/keys.dart
import 'common/widgets/keys.dart';
import 'features/home/keys.dart';
import 'features/profile/keys.dart';

final keys = Keys();

class Keys {
   final home = HomeKeys();
   final profile = ProfileKeys();
   final widgets = WidgetKeys();
}
Grouping keys: /lib/features/product/keys.dart
dart
import 'package:flutter/widgets.dart';

class _ProductPageKey extends ValueKey<String> {
  const _ProductPageKey(String value) : super('productPage_$value');
}
class ProductPageKeys {
  final menuIconButton = const _ProductPageKey('menuIconButton');
  final productImage = const _ProductPageKey('productImage');
  final productName = const _ProductPageKey('productName');
}
class _ProductConnectingPageKey extends ValueKey<String> {
  const _ProductConnectingPageKey(String value)
    : super('productConnectingPage_$value');
}
class ProductConnectingPageKeys {
  final productImage = const _ProductConnectingPageKey('productImage');
  final productName = const _ProductConnectingPageKey('productName');
}
Common widgets: /lib/common/widgets/keys.dart
dart
import 'package:flutter/widgets.dart';

class _WidgetKey extends ValueKey<String> {
  const _WidgetKey(String value) : super('widget_$value');
}
class WidgetKeys {
  final addButton = const _WidgetKey('addButton');
  _WidgetKey assetRow(String coinTitle) => _WidgetKey('assetRow_$coinTitle');
  _WidgetKey assetSlider(SelectAssetType type) =>
      _WidgetKey('assetSlider_$type');
  final cancelButton = const _WidgetKey('cancelButton');
  final pickCurrencyButton = const _WidgetKey('pickCurrencyButton');
  final saveButton = const _WidgetKey('saveButton');
  final searchBar = const _WidgetKey('searchBar');
  final topBarHeaderMiddleText = const _WidgetKey('topBarHeaderMiddleText');
}
External packages: widgetbook/keys.dart:
dart
import 'package:flutter/widgets.dart';
final widgetKeys = WidgetBookKeys();
class _WidgetBookKey extends ValueKey<String> {
  const _WidgetBookKey(String value) : super('widgetBook_$value');
}
class WidgetBookKeys {
  final tile = const _WidgetBookKey('tile');
}
Importing keys from external packages to main lib/keys.dart:
dart
import 'package:common_ui/widgets/keys.dart' as ds;
final keys = Keys();
class Keys {
  final widgetKeys = ds.widgetKeys;
}
Example of assigning a parameterized key to a widget:
dart
enum SizeDTO {
  small,
  medium,
  large,
}

class PickSizeWidget extends StatelessWidget {
  Widget _sizeButton(SizeDTO size) {
    return _Button(
      key: keys.pickSize.sizeButton(size),
      value: size,
      currentValue: value,
      onPressed: onPressed,
    );
  }

  
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _sizeButton(SizeDTO.small),
        _sizeButton(SizeDTO.medium),
        _sizeButton(SizeDTO.large),
      ].spaced(24),
    );
  }
}
keys.dart:
dart
  _PickSizeKey sizeButton(SizeDTO size) => _PickSizeKey('sizeButton_${size.name}');
  • 仅为参与测试的组件分配键
  • 仅向现有组件添加
    key
    参数
  • 必要时创建新的keys.dart文件
  • 每个功能目录最多只能有一个keys.dart文件,但该文件可包含多个类
  • 添加keys.dart文件的导入
  • 更新主keys.dart聚合器
  • 绝不要修改组件签名
  • 绝不要重构现有代码结构
  • 绝不要在应用中硬编码键,必须使用keys.dart文件中的键
  • 绝不要在应用中创建新组件
  • 绝不要创建未分配给组件的键
  • 务必确保每个键值都是唯一的
  • 务必创建keys.dart来存储所属功能/组件的键,不要放在主lib目录下
  • 务必按字母顺序对键进行排序
  • lib/keys.dart
    中的主
    Keys
    类应仅导入并聚合来自各功能专属keys.dart文件的键
  • 务必使用以下精确模式分配键:
    key: keys.feature.widgetName
    (除非使用参数化键)
  • 将键作为组件构造函数的第一个参数
  • 按屏幕名称将相关键分组到类中(例如:
    HomeKeys
    )。使用
    ValueKey<String>
    的私有子类,为所有键值添加页面或组件名称前缀
  • 通用组件的键存储在WidgetKeys类中。包含该类的文件应放在通用组件定义的目录中
  • 对于位于单独包中的组件(例如:widgetbook),遵循以下模式:在
    widgetbook
    目录中创建
    keys.dart
    文件,包含
    WidgetKeys
    类。这些键通过
    widgetKeys.tile
    分配。然后在lib/keys.dart中,通过
    final widgetKeys = ds.widgetKeys;
    将其添加到页面列表中(ds为导入的包,例如:
    import 'package:common_ui/widgets/keys.dart' as ds;
  • 如果组件不唯一(例如:从列表生成),使用参数化键
    • 务必优先使用已存在的枚举或DTO作为参数
    • 使用组件的现有属性作为参数化键
    • 绝不要在应用中分配参数化键后,在keys文件中使用固定值(反之亦然)
    • 绝不要创建辅助方法,改用参数化键
    • 当组件从现有枚举或DTO生成时,务必使用带有这些枚举/DTO值作为参数的参数化键
在以下情况使用独立键:
  • 组件是硬编码的,编译时即可确定
  • 组件具有独特且有意义的名称
在以下情况使用参数化键:
  • 组件从动态数据生成
  • 组件从DTO或枚举生成
  • 组件数量可变或较多
  • 组件通过循环或列表生成
为组件分配键的步骤:
  1. 确定测试所需的组件
  2. 创建键分配:
    key: keys.feature.widgetName
  3. 在keys.dart文件中定义键
  4. 验证键已分配给组件
文件结构示例:
功能专属键:
lib/features/home/keys.dart
lib/features/profile/keys.dart
lib/features/auth/keys.dart
lib/common/widgets/keys.dart
主键聚合器:
lib/keys.dart (导入并聚合所有功能键)
示例
功能专属keys.dart:
dart
// lib/features/home/keys.dart
import 'package:flutter/widgets.dart';

class _HomeKey extends ValueKey<String> {
  const _HomeKey(String value) : super('home_$value');
}
class HomeKeys {
  final menuIconButton = const _HomeKey('menuIconButton');
  _HomeKey navbarItem(String label) => _HomeKey('navbarItem_$label');
}
主键聚合器:
dart
// lib/keys.dart
import 'common/widgets/keys.dart';
import 'features/home/keys.dart';
import 'features/profile/keys.dart';

final keys = Keys();

class Keys {
   final home = HomeKeys();
   final profile = ProfileKeys();
   final widgets = WidgetKeys();
}
键分组: /lib/features/product/keys.dart
dart
import 'package:flutter/widgets.dart';

class _ProductPageKey extends ValueKey<String> {
  const _ProductPageKey(String value) : super('productPage_$value');
}
class ProductPageKeys {
  final menuIconButton = const _ProductPageKey('menuIconButton');
  final productImage = const _ProductPageKey('productImage');
  final productName = const _ProductPageKey('productName');
}
class _ProductConnectingPageKey extends ValueKey<String> {
  const _ProductConnectingPageKey(String value)
    : super('productConnectingPage_$value');
}
class ProductConnectingPageKeys {
  final productImage = const _ProductConnectingPageKey('productImage');
  final productName = const _ProductConnectingPageKey('productName');
}
通用组件: /lib/common/widgets/keys.dart
dart
import 'package:flutter/widgets.dart';

class _WidgetKey extends ValueKey<String> {
  const _WidgetKey(String value) : super('widget_$value');
}
class WidgetKeys {
  final addButton = const _WidgetKey('addButton');
  _WidgetKey assetRow(String coinTitle) => _WidgetKey('assetRow_$coinTitle');
  _WidgetKey assetSlider(SelectAssetType type) =>
      _WidgetKey('assetSlider_$type');
  final cancelButton = const _WidgetKey('cancelButton');
  final pickCurrencyButton = const _WidgetKey('pickCurrencyButton');
  final saveButton = const _WidgetKey('saveButton');
  final searchBar = const _WidgetKey('searchBar');
  final topBarHeaderMiddleText = const _WidgetKey('topBarHeaderMiddleText');
}
外部包: widgetbook/keys.dart:
dart
import 'package:flutter/widgets.dart';
final widgetKeys = WidgetBookKeys();
class _WidgetBookKey extends ValueKey<String> {
  const _WidgetBookKey(String value) : super('widgetBook_$value');
}
class WidgetBookKeys {
  final tile = const _WidgetBookKey('tile');
}
将外部包的键导入到主lib/keys.dart:
dart
import 'package:common_ui/widgets/keys.dart' as ds;
final keys = Keys();
class Keys {
  final widgetKeys = ds.widgetKeys;
}
为组件分配参数化键的示例:
dart
enum SizeDTO {
  small,
  medium,
  large,
}

class PickSizeWidget extends StatelessWidget {
  Widget _sizeButton(SizeDTO size) {
    return _Button(
      key: keys.pickSize.sizeButton(size),
      value: size,
      currentValue: value,
      onPressed: onPressed,
    );
  }

  
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _sizeButton(SizeDTO.small),
        _sizeButton(SizeDTO.medium),
        _sizeButton(SizeDTO.large),
      ].spaced(24),
    );
  }
}
keys.dart:
dart
  _PickSizeKey sizeButton(SizeDTO size) => _PickSizeKey('sizeButton_${size.name}');

Code Examples

代码示例

Module Structure

模块结构

Feature module example
patrol_test/modules/home.dart
dart
import 'package:patrol/patrol.dart';
import 'module.dart';

final class Home extends Module {
  Home(super.$);

  Future<void> navigateToSettings() async {
    await $(keys.home.settingsButton).scrollTo().tap();
  }

  Future<void> searchForItem(String searchPhrase) async {
    await $(keys.home.searchButton).scrollTo().tap();
    await $(keys.home.searchInput).enterText(searchPhrase);
    await $(keys.home.searchSubmitButton).tap();
  }
}
Modules aggregator
patrol_test/modules/modules.dart
dart
final class Modules {
  Modules(this._$);
  final PatrolIntegrationTester _$;

  late final home = Home(_$);
  late final auth = Auth(_$);
}
功能模块示例
patrol_test/modules/home.dart
dart
import 'package:patrol/patrol.dart';
import 'module.dart';

final class Home extends Module {
  Home(super.$);

  Future<void> navigateToSettings() async {
    await $(keys.home.settingsButton).scrollTo().tap();
  }

  Future<void> searchForItem(String searchPhrase) async {
    await $(keys.home.searchButton).scrollTo().tap();
    await $(keys.home.searchInput).enterText(searchPhrase);
    await $(keys.home.searchSubmitButton).tap();
  }
}
模块聚合器
patrol_test/modules/modules.dart
dart
final class Modules {
  Modules(this._$);
  final PatrolIntegrationTester _$;

  late final home = Home(_$);
  late final auth = Auth(_$);
}

System Class

System类

patrol_test/modules/system.dart
dart
final class System extends PlatformAutomator {
  System({required super.config});

  Future<void> checkIfNativePlayerIsVisible() async {
    // Implementation
  }
}
patrol_test/modules/system.dart
dart
final class System extends PlatformAutomator {
  System({required super.config});

  Future<void> checkIfNativePlayerIsVisible() async {
    // Implementation
  }
}

ApiClients Class

ApiClients类

patrol_test/modules/api_clients.dart
dart
final class ApiClients {
  final backend = BackendClient();
  final mailpitClient = MailpitClient();
}
patrol_test/modules/api_clients.dart
dart
final class ApiClients {
  final backend = BackendClient();
  final mailpitClient = MailpitClient();
}

Complete Test Example

完整测试示例

dart
testApp('Download a chapter and play it offline', ($, modules, system, apiClients) async {
    await modules.auth.getAuthToken();
    await apiClients.backend.addFavourites();
    await openApp($);
    await modules.home.goToOldTestament();
    await modules.testament.expandBook(bookName: 'Book of Revelation');
    await modules.testament.chooseChapterOfBook(
      bookName: 'Book of Revelation',
      chapterIndex: 0,
    );
    await modules.player.expandChapters();
    await modules.player.downloadChapter(chapterIndex: 9);
    await modules.player.waitUntilDownloaded();
    // usage of a method from $.platform Equivalent of await $.platform.mobile.enableAirplaneMode();
    await system.enableAirplaneMode();
    await modules.player.rollDownChapters();
    await modules.player.closeChapterPlayer();
    await modules.testament.closeTestament();
    await modules.bottomNavigation.goToLibrary();
    await modules.library.goToDownloads();
    await modules.downloads.expandOldTestament();
    await modules.downloads.goToBook(bookName: 'Book of Revelation');
    await modules.player.checkIfChapterIsCorrect(chapterIndex: 0);
    await modules.player.playCurrentTrack();
    // usage of our method, which calls many methods from $.platform
    await system.checkIfNativePlayerIsVisible();
  });
dart
testApp('Download a chapter and play it offline', ($, modules, system, apiClients) async {
    await modules.auth.getAuthToken();
    await apiClients.backend.addFavourites();
    await openApp($);
    await modules.home.goToOldTestament();
    await modules.testament.expandBook(bookName: 'Book of Revelation');
    await modules.testament.chooseChapterOfBook(
      bookName: 'Book of Revelation',
      chapterIndex: 0,
    );
    await modules.player.expandChapters();
    await modules.player.downloadChapter(chapterIndex: 9);
    await modules.player.waitUntilDownloaded();
    // usage of a method from $.platform Equivalent of await $.platform.mobile.enableAirplaneMode();
    await system.enableAirplaneMode();
    await modules.player.rollDownChapters();
    await modules.player.closeChapterPlayer();
    await modules.testament.closeTestament();
    await modules.bottomNavigation.goToLibrary();
    await modules.library.goToDownloads();
    await modules.downloads.expandOldTestament();
    await modules.downloads.goToBook(bookName: 'Book of Revelation');
    await modules.player.checkIfChapterIsCorrect(chapterIndex: 0);
    await modules.player.playCurrentTrack();
    // usage of our method, which calls many methods from $.platform
    await system.checkIfNativePlayerIsVisible();
  });