关于 patrol-test-architecture
patrol-test-architecture 是面向 Patrol 测试中采用 LeanCode 的 Modules/System/ApiClients 模式项目的 Agent 技能。它扩展 patrol-write-test,要求使用 testApp 包装器、按模块组织代码,并在重构后避免测试文件直接调用 Patrol API。
主要功能
- 01使用 testApp 包装器
- 02按 Modules、System 和 ApiClients 组织测试
- 03每个测试文件仅包含一个测试
- 04将键保存在功能专属文件中
- 05测试通过后将 Patrol 调用移入模块
适用场景
- 01为采用 Modules/System/ApiClients 模式的项目构建测试
- 02将通过的测试重构为模块形式
- 03整理功能专属测试键
MCP 相关
该技能基于 patrol-write-test,并沿用其关于使用 Patrol MCP 运行和检查测试的指导。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ leancodepl/ patrol --skill patrol-test-architecture安装后在 Claude Code 中输入/patrol-test-architecture确认已加载。
详细介绍
此技能基于 patrol-write-test:请遵循其中的操作顺序、Patrol MCP 用法、Patrol 测试规则和测试键,并在此基础上应用以下更改。如果两者有冲突,以此技能为准。
操作顺序变更
- 首先直接读取关键文件(模块聚合文件、测试包装器、主
keys.dart,以及与场景相关的具体应用功能文件)。这些路径是已知的——不要搜索,直接打开 - 查找现有模块中可复用或调整的函数
- 编写测试时,仍要直接在测试文件中编写 Patrol 操作——暂时不要在模块中创建新方法
- 完整测试通过后,将新增代码重组到现有模块或新模块中,而不是保留为普通的可复用函数。此项为强制要求——测试文件只能调用模块方法,不得直接使用 Patrol API
Patrol 测试架构
结构
- 所有测试都使用自定义的
testApp包装器 - 每个测试文件只能包含一个测试
- 使用
Modules、System和ApiClients模式组织测试 - 每个模块代表一个面向用户的功能(例如,Auth、主屏幕、下载、播放器)
System是一个用于原生交互的类,专门处理需要通过$.platform执行、且不属于我们应用的操作,例如启用飞行模式来测试离线模式。除非方法不适合放在应用的任何模块中,否则不要放在这里ApiClients是一个聚合 API 客户端的类,用于 API 通信。对于需要通信的不同 API,应分别创建客户端,例如用于测试的邮件服务器、我们的测试后端或第三方服务
模块中的方法组织
- 不要在每个方法中都写注释,使用描述性名称即可
- 当模块中的方法因过长而难以阅读和浏览,且包含多个可明确命名的逻辑步骤时,应将其拆分
- 如果一系列步骤作为一个整体会在多个测试中复用(例如较长的引导流程),则在模块中创建一个包装方法,并在其中调用每个步骤对应的私有方法
测试键
除 patrol-write-test 中的测试键规则外,键还会放在各功能专属的 keys.dart 文件中,并由主 Keys 类汇总:
- 如有需要,请创建新的 keys.dart 文件
- 每个功能目录最多只能有一个 keys.dart 文件,但文件中可以包含多个类
- 始终在其所属功能或组件的 keys.dart 中存储键,而不要放在主 lib 目录中
- 为 keys.dart 文件添加导入
- 更新主 keys.dart 汇总文件
lib/keys.dart中的主Keys类只能导入并汇总功能专属 keys.dart 文件中的键- 始终按照以下精确模式分配键:
key: keys.feature.widgetName(使用参数化键时除外) - 将相关键分组到以屏幕命名的类中(例如
HomeKeys)。使用ValueKey<String>的私有子类,为所有键值添加页面或组件名称前缀 - 对于通用组件,将键存放在
WidgetKeys类中。包含该类的文件应放在定义通用组件的目录中 - 对于位于独立软件包中的组件(例如 widgetbook),请遵循以下模式:在
widgetbook目录中创建包含WidgetKeys类的keys.dart文件。这些键通过widgetKeys.tile分配。然后,在 keys.dart lib/ 中使用final widgetKeys = ds.widgetKeys;将其添加到页面列表中(ds 是导入的软件包,例如import 'package:common_ui/widgets/keys.dart' as ds;)
为组件分配键的步骤:
- 确定需要测试的组件
- 创建键分配:
key: keys.feature.widgetName - 在 keys.dart 文件中定义键
- 验证键已分配给组件
文件结构示例:
lib/features/home/keys.dart
lib/features/profile/keys.dart
lib/features/auth/keys.dart
lib/common/widgets/keys.dart
主键汇总文件:
lib/keys.dart (imports and aggregates all feature keys)
示例
功能专属 keys.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');
}
主键汇总文件:
// 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
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
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:
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:
import 'package:common_ui/widgets/keys.dart' as ds;
final keys = Keys();
class Keys {
final widgetKeys = ds.widgetKeys;
}
为组件分配参数化键的示例:
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,
);
}
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sizeButton(SizeDTO.small),
_sizeButton(SizeDTO.medium),
_sizeButton(SizeDTO.large),
].spaced(24),
);
}
}
keys.dart:
_PickSizeKey sizeButton(SizeDTO size) => _PickSizeKey('sizeButton_${size.name}');
代码示例
模块结构
功能模块示例
patrol_test/modules/home.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
final class Modules {
Modules(this._$);
final PatrolIntegrationTester _$;
late final home = Home(_$);
late final auth = Auth(_$);
}
系统类
patrol_test/modules/system.dart
final class System extends PlatformAutomator {
System({required super.config});
Future<void> checkIfNativePlayerIsVisible() async {
// Implementation
}
}
ApiClients 类
patrol_test/modules/api_clients.dart
final class ApiClients {
final backend = BackendClient();
final mailpitClient = MailpitClient();
}
完整测试示例
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();
});