patrol-test-architecture について
patrol-test-architectureは、PatrolテストでLeanCodeのModules/System/ApiClientsパターンを使うプロジェクト向けのエージェントスキルです。patrol-write-testを拡張し、testAppラッパーの使用、モジュール構成、リファクタリング後にテストファイルからPatrol APIを直接呼ばないことを求めます。
主な機能
- 01testAppラッパーの使用
- 02Modules、System、ApiClientsによるテスト構成
- 03各テストファイルにテストを1つ配置
- 04機能別ファイルへのキー保存
- 05テスト合格後にPatrol呼び出しをモジュールへ移動
活用シーン
- 01Modules/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 のテストルールとテストキーについては同スキルに従い、以下の変更を加えてください。両者の内容が異なる場合は、このスキルの指示を優先してください。
アクションの順序の変更
- 最初に主要なファイルを直接読みます(モジュールの集約ファイル、テストラッパー、main keys.dart、およびシナリオに関係するアプリの機能ファイル)。これらのパスは既知なので、検索せずに直接開いてください。
- 既存のモジュールで再利用または調整できる関数を探します。
- テストを作成する際は、引き続きテストファイル内に Patrol のアクションを直接記述してください。この段階では、モジュールに新しいメソッドを作成しないでください。
- テストが完全に通った後、新しいコードを通常の再利用可能な関数のままにせず、既存または新規のモジュールに整理し直してください。これは必須です。テストファイルではモジュールのメソッドのみを呼び出し、Patrol API を直接使用してはいけません。
Patrol テストアーキテクチャ
構造
- すべてのテストにカスタムの
testAppラッパーを使用します。 - 各テストファイルには、テストを 1 つだけ含めます。
Modules、System、ApiClientsのパターンを使用してテストを構成します。- 各モジュールは、ユーザーの視点から見た機能(例:認証、ホーム画面、ダウンロード、プレーヤー)を表します。
Systemはネイティブ操作用のクラスです。具体的には、アプリの外部で行う必要があり、$.platformを使用して実行する操作(オフラインモードのテストで機内モードを有効にするなど)を扱います。アプリのどのモジュールにも当てはまらない場合を除き、ここにメソッドを追加しないでください。ApiClientsは、API 通信用の API クライアントをまとめるクラスです。テスト用のメールサーバー、テスト用バックエンド、サードパーティサービスなど、通信が必要な API ごとに個別のクライアントを用意します。
モジュール内のメソッドの構成
- すべてのメソッドにコメントを書かず、わかりやすい名前を付けてください。
- メソッドが長く、長さのために読みづらく、全体を把握しにくい場合や、説明的な名前を付けられる複数の論理的な手順で構成されている場合は、モジュール内でメソッドを分割してください。
- まとまりとして複数のテストで再利用される手順の一連の流れ(例:長いオンボーディングフロー)がある場合は、各手順に対応するプライベートメソッドを呼び出すラッパーメソッドをモジュール内に作成してください。
テストキー
patrol-write-test のテストキーに関するルールに加えて、キーは機能ごとの keys.dart ファイルに配置し、メインの Keys クラスで集約します。
- 必要に応じて新しい keys.dart ファイルを作成します
- 機能ディレクトリごとに keys.dart ファイルは必ず最大1つにします。ただし、ファイルには複数のクラスを含めることができます
- キーは必ず、対象の機能/ウィジェット用の 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
メインの keys アグリゲーター:
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');
}
メインの keys アグリゲーター:
// 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(_$);
}
System クラス
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();
});