mcpField
P
patrol-test-architecture

patrol-test-architecture

github.com/leancodepl测试与质量1.5k374 次安装今天更新
代码仓库

关于 patrol-test-architecture

patrol-test-architecture 是面向 Patrol 测试中采用 LeanCode 的 Modules/System/ApiClients 模式项目的 Agent 技能。它扩展 patrol-write-test,要求使用 testApp 包装器、按模块组织代码,并在重构后避免测试文件直接调用 Patrol API。

主要功能

  1. 01使用 testApp 包装器
  2. 02按 Modules、System 和 ApiClients 组织测试
  3. 03每个测试文件仅包含一个测试
  4. 04将键保存在功能专属文件中
  5. 05测试通过后将 Patrol 调用移入模块

适用场景

  1. 01为采用 Modules/System/ApiClients 模式的项目构建测试
  2. 02将通过的测试重构为模块形式
  3. 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;)

为组件分配键的步骤:

  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 (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();
  });

评论

支持 Markdown