核心摘要 (TL;DR)
背景 :为 UseUp 项目实现全面 E2E 测试,从 integration_test 迁移到 Patrol(支持原生 OS 交互)。架构 :模块化测试结构(Dashboard、Inventory、Settings、History),共享初始化 helper。核心问题 :Isar “Instance has already been opened” 错误、patrol_cli 版本不兼容、物品保存需过期日期、本地化文本匹配。关键解法 :每个模块单 patrolTest、Isar 单例复用、添加过期日期选择、使用 findsWidgets + .first、修正本地化文本。 问题概览卡片 基本信息
项目背景 :Flutter 项目使用 Riverpod + GoRouter + Isar,需要全面 E2E 测试。技术栈 :Flutter 3.41.1, Patrol 3.20.0, patrol_cli 3.11.0, Isar 3.3.0-dev.1测试目标 :Dashboard、Inventory(增删改查)、Settings、History 四大模块运行环境 :Android 模拟器 (emulator-5554), iOS 模拟器 (iPhone 16e) 错误日志复现 1 2 3 4 5 6 7 8 9 10 11 12 13 # 问题 1: Patrol 版本不兼容 Version incompatibility detected! patrol_cli 4.4.0 is not compatible with patrol 3.20.0. # 问题 2: Android 依赖冲突 Cannot find a version of 'androidx.test:runner' that satisfies the version constraints: Dependency path 'android:app:unspecified' --> 'androidx.test:runner:1.6.2' Constraint path 'android:app:unspecified' --> 'androidx.test:runner:{strictly 1.5.1}' # 问题 3: Isar 实例冲突 IsarException: Instance has already been opened # 问题 4: Patrol 测试执行失败 (500 Internal Server Error) pl.leancode.patrol.contracts.PatrolAppServiceClientException: Invalid response 500
1. 现象描述与现场还原 初始尝试:integration_test 的局限 最初使用 integration_test 包编写 E2E 测试,但遇到以下问题:
Isar 数据库在测试环境多次初始化时报错 “Instance has already been opened” 无法测试原生 OS 交互(权限对话框、文件选择器、通知) 测试隔离性差,状态泄漏 迁移到 Patrol 选择 Patrol 的原因:
支持原生 OS 交互(权限、文件选择器、通知) 更简洁的 API($('text')、$(Icons.xxx)) 与 Flutter 测试框架无缝集成 配置 Android 支持 修改后的配置:
1 2 3 4 5 6 7 8 defaultConfig { testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner" } dependencies { androidTestImplementation("androidx.test:runner:1.5.1" ) }
创建 Android 测试入口:
1 2 3 4 5 6 7 8 9 10 11 12 13 @RunWith(Parameterized.class) public class MainActivityTest { @Parameters(name = "{0}") public static Object[] testCases() { PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); instrumentation.setUp(MainActivityTest.class); instrumentation.waitForPatrolAppService(); return instrumentation.listDartTests(); } }
2. 根本原因分析 问题 1: Patrol CLI 版本不兼容 pubspec.yaml 指定 patrol: ^3.14.0,但全局安装的 patrol_cli 为 4.4.0,两者不兼容。
解决方案:
1 dart pub global activate patrol_cli 3.11.0
问题 2: Android 依赖版本冲突 Gradle 要求 androidx.test:runner:1.5.1,但代码中引入了 1.6.2。
解决方案:
1 androidTestImplementation("androidx.test:runner:1.5.1" )
问题 3: 多个 patrolTest 导致 Isar 冲突 最初在一个模块文件中编写多个 patrolTest,每个测试都会调用 initializeTestApp(),导致 Isar 实例重复打开。
原因分析:
Patrol Android runner 将每个 patrolTest 作为独立的 instrumented test 多个测试在同一进程中运行时,Isar 实例状态冲突 Patrol 的 PatrolAppServiceClient 在后续测试中返回 500 错误 解决方案:
每个模块文件只保留一个 patrolTest,内部通过步骤分割测试场景 Isar 实例使用单例模式复用 1 2 3 4 5 6 7 8 9 10 11 12 Isar? _isarInstance; Future<ProviderScope> initializeTestApp() async { if (_isarInstance == null || !_isarInstance!.isOpen) { _isarInstance = await Isar.open(...); } await _isarInstance!.writeTxn(() async { await _isarInstance!.items.clear(); }); }
问题 4: 物品保存失败(缺少过期日期) 点击 “Save” 后无反应,测试卡在添加物品页面。
原因: 业务逻辑要求 expiryDate 必须设置,否则保存返回 false。
解决方案: 测试中添加过期日期选择步骤
1 2 3 4 5 await $(Icons.calendar_today_outlined).tap();await $.pumpAndSettle();await $('OK' ).tap();await $.pumpAndSettle();await $('Save' ).tap();
问题 5: 物品文本匹配失败(“too many”) 物品保存后,expect($('Test Apple'), findsOneWidget) 报错 “too many”。
原因: Dashboard 顶部有 “Expiring Soon” 横向卡片,物品同时出现在横滑卡片和主列表中。
解决方案:
1 2 expect($('Test Apple' ), findsWidgets); await $('Test Apple' ).first.tap();
问题 6: 本地化文本不匹配 Settings 测试中 expect($('Data Overview'), findsOneWidget) 失败。
原因: 文本是本地化的,英文版本中 dataOverview = “Overview” 而非 “Data Overview”。
解决方案:
1 2 3 expect($('Overview' ), findsOneWidget); expect($('Backup & Restore' ), findsOneWidget);
问题 7: GoRouter pressBack 导航问题 从 Settings 子页面(如 Categories)按返回键后,预期回到 Settings,但实际回到了 Dashboard。
原因: GoRouter 的路由栈管理,多次 pressBack 可能超出预期层级。
解决方案: 重新排序测试步骤,减少对 pressBack 的依赖,或每次子页面测试后重新导航到 Settings。
3. 解决方案 步骤一:添加 Patrol 依赖 1 2 3 4 dev_dependencies: patrol: ^3.20.0 patrol_finders: ^2.4.1
步骤二:配置 Android 1 2 3 4 5 6 7 defaultConfig { testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner" } dependencies { androidTestImplementation("androidx.test:runner:1.5.1" ) }
步骤三:创建测试 Helper 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Isar? _isarInstance; final patrolTesterConfig = PatrolTesterConfig( settleTimeout: const Duration (seconds: 10 ), existsTimeout: const Duration (seconds: 10 ), ); Future<ProviderScope> initializeTestApp() async { if (_isarInstance == null || !_isarInstance!.isOpen) { _isarInstance = await Isar.open(...); } await _isarInstance!.writeTxn(() async { await _isarInstance!.items.clear(); await _isarInstance!.locations.clear(); await _isarInstance!.categorys.clear(); }); await DatabaseSeeder.seedAll(_isarInstance!); return ProviderScope(...); }
步骤四:编写模块化测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 void main() { patrolTest('Dashboard - full module test' , config: patrolTesterConfig, ($) async { final app = await initializeTestApp(); await $.pumpWidgetAndSettle(app); expect($('UseUp' ), findsOneWidget); expect($(Icons.add), findsOneWidget); await $(Icons.search).tap(); await $.pumpAndSettle(); expect($(TextField), findsOneWidget); await $(Icons.filter_list).tap(); await $.pumpAndSettle(); expect($('Category' ), findsWidgets); }); }
步骤五:更新 Makefile 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ANDROID_HOME := $(HOME) /Library/Android/sdk PATROL := ANDROID_HOME="$(ANDROID_HOME) " PATH="$(HOME) /.pub-cache/bin:/Users/mac/fvm/versions/stable/bin:$(ANDROID_HOME) /platform-tools:$(PATH) " patrol .PHONY : e2e-smokee2e-smoke: $(PATROL) test -d emulator-5554 -t integration_test/app_test.dart .PHONY : e2e-androide2e-android: $(PATROL) test -d emulator-5554 -t integration_test/modules/dashboard_test.dart $(PATROL) test -d emulator-5554 -t integration_test/modules/inventory_test.dart $(PATROL) test -d emulator-5554 -t integration_test/modules/settings_test.dart $(PATROL) test -d emulator-5554 -t integration_test/modules/history_test.dart .PHONY : e2e-dashboarde2e-dashboard: $(PATROL) test -d emulator-5554 -t integration_test/modules/dashboard_test.dart
4. 预防与建议 单测试单模块原则 :每个模块文件只包含一个 patrolTest,通过步骤分割测试场景,避免 Isar 实例冲突。Isar 单例复用 :使用全局变量 _isarInstance 复用数据库实例,每次测试开始时清理数据而非重新打开。业务逻辑前置了解 :编写测试前先了解业务规则(如物品必须有过期日期),避免因验证失败导致的测试卡住。本地化文本检查 :测试中使用英文环境时,需确认英文翻译文本,避免因语言差异导致的断言失败。UI 结构考虑 :了解 UI 布局(如横滑卡片 + 主列表),使用 findsWidgets 和 .first 处理多匹配场景。版本兼容性表 :参考 Patrol 兼容性表 确保 patrol_cli 与 patrol 版本匹配。 5. 最终成果 测试覆盖 模块 测试内容 状态 Dashboard 启动验证、搜索、筛选 ✅ Android / ✅ iOS Inventory 添加、验证、详情、消耗、删除 ✅ Android / ✅ iOS Settings 数据管理、分类、存储位置、语言 ✅ Android / ✅ iOS History 空状态、消耗后出现、补货 ✅ Android / ✅ iOS Smoke 跨模块快速验证 ✅ Android
iOS 排坑详情 :参见 Patrol iOS 集成测试排坑实录
运行命令 1 2 3 4 5 6 7 make e2e-smoke make e2e-android make e2e-ios make e2e-dashboard make e2e-inventory make e2e-settings make e2e-history
—.