بنقرة واحدة
patrol-tests-architecture
// Rules for writing Patrol E2E tests with LeanCode's recommended architecture (modules, system, api clients)
// Rules for writing Patrol E2E tests with LeanCode's recommended architecture (modules, system, api clients)
| name | patrol-tests-architecture |
| description | Rules for writing Patrol E2E tests with LeanCode's recommended architecture (modules, system, api clients) |
When working with Patrol tests:
patrol-run({ "testFile": "patrol_test/your_test.dart" }) to run tests and wait for completionpatrol-screenshot({ "platform": "android" }) or patrol-screenshot({ "platform": "ios" }) to capture screenshots for debugging test failurespatrol-quit({}) to quit the session gracefullypatrol-status({}) to check current status and recent outputpatrol-native-tree({}) to fetch the current native UI tree hierarchy for writing native interactions and interactions with apps other than the app under test.testApp wrapper for all testsModules, System, and ApiClients pattern$(), .scrollTo(), .tap(), .enterText(), .waitUntilVisible(), etc.) should import 'package:patrol/patrol.dart';$.platform methods before implementing test actionspatrol test for all tests. Never use flutter test command$.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 testwaitUntilVisible as assertion at the end of the testexpect() for assertions only when waitUntilVisible is not enough$.platform.mobile.grantPermissionWhenInUse over $.platform.mobile.tapAssign 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
Use individual keys when:
Use parameterized keys when:
Steps to assign a key to a widget:
key: keys.feature.widgetNameFile 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:
// 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:
// 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
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
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:
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:
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:
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}');
Feature module example
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();
}
}
Modules aggregator
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
}
}
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();
});