Patrol iOS 集成测试 xcodebuild exit code 70 全链路排查 - Flutter 测试实战 02
问题概览卡片
基本信息
- 项目背景:Flutter 项目使用 Riverpod + GoRouter + Isar,已在 Android 上完成 E2E 测试。
- 技术栈:Flutter 3.41.1, Patrol 3.20.0, patrol_cli 3.11.0, Xcode 16.x
- 测试目标:将 Dashboard、Inventory、Settings、History 四大模块测试扩展到 iOS 模拟器
- 运行环境:iOS 模拟器 (iPhone 16e)
错误日志复现
1 | # 问题 1: xcodebuild 退出码 70,测试数量为 0 |
1. 现象描述与现场还原
从 Android 到 iOS
Android E2E 测试全部通过后,执行 patrol test -d "iPhone 16e" 启动 iOS 测试。预期:像 Android 一样直接跑起来。实际:xcodebuild exited with code 70,Total: 0,测试完全没有被发现。
排查过程时间线
整个调试过程跨越了多个问题层级,每修复一个问题就暴露下一个:
- Unit Test vs UI Test — 测试 target 类型不对
- 目标命名 — Patrol CLI 硬编码期望
RunnerUITests,不是RunnerTests - 框架嵌入 —
inherit! :search_paths不够,需要:complete - 设备匹配 —
-destination-timeout 1太短 +SUPPORTED_PLATFORMS不全 - 模拟器克隆 —
parallelizable="YES"导致克隆 - 返回导航 —
pressBack()在 iOS 上不存在
2. 根本原因分析
问题 1: RunnerTests 是 Unit Test Bundle,不是 UI Testing Bundle
Flutter 默认创建的 RunnerTests target 的 productType 是 com.apple.product-type.bundle.unit-test。Patrol 需要的是 com.apple.product-type.bundle.ui-testing。
差异对比:
| 设置 | Unit Test Bundle | UI Testing Bundle (Patrol) |
|---|---|---|
productType | .bundle.unit-test | .bundle.ui-testing |
TEST_HOST | 需要设置 | 不需要 |
BUNDLE_LOADER | 需要设置 | 不需要 |
TEST_TARGET_NAME | 不需要 | 需要 = Runner |
| Podfile 继承 | inherit! :search_paths | inherit! :complete |
修改 project.pbxproj:
1 | - productType = "com.apple.product-type.bundle.unit-test"; |
同时删除 BUNDLE_LOADER 和 TEST_HOST,添加 TEST_TARGET_NAME = Runner。
问题 2: Patrol CLI 期望 RunnerUITests,而非 RunnerTests
深入 Patrol CLI 源码 (app_options.dart) 发现:
1 | // patrol_cli 源码中硬编码 |
无论你的 Xcode 项目中 target 叫什么名字,Patrol CLI 都会固定查找 RunnerUITests。
解决方案: 全面重命名:
- 目录:
ios/RunnerTests/→ios/RunnerUITests/ - 文件:
RunnerTests.swift→RunnerUITests.m(改用 Objective-C + Patrol 宏) project.pbxproj中所有引用Podfile中 target 名称
创建 Objective-C 测试入口:
1 | // ios/RunnerUITests/RunnerUITests.m |
问题 3: CocoaAsyncSocket 框架加载失败
1 | Library not loaded: CocoaAsyncSocket.framework/CocoaAsyncSocket |
UI Testing Bundle 运行在独立进程中,需要完整嵌入所有依赖框架。
修改 Podfile:
1 | # 修改前 |
问题 4: 设备匹配失败 — destination-timeout 与 SUPPORTED_PLATFORMS
即使 target 配置正确,xcodebuild test-without-building 仍然报:
1 | Supported platforms for the buildables in the current scheme is empty. |
两层原因:
4a. SUPPORTED_PLATFORMS 不完整
project.pbxproj 中项目级别的 Release/Profile 配置:
1 | - SUPPORTED_PLATFORMS = iphoneos; |
4b. Patrol CLI 硬编码 -destination-timeout 1
patrol_cli 构造的 xcodebuild 命令中 -destination-timeout 只有 1 秒。当 SUPPORTED_PLATFORMS 不完整时,xcodebuild 需要更长时间解析设备。
但更根本的问题是 Patrol CLI 使用 -destination platform=iOS Simulator,OS=latest,name=iPhone 16e 按名称匹配设备,在 “Supported platforms is empty” 场景下完全无法工作。
解决方案:修补本地 patrol_cli 源码,使用设备 UUID 直接匹配:
1 | // ~/.pub-cache/hosted/.../patrol_cli-3.11.0/lib/src/crossplatform/app_options.dart |
注意: 修改源码后需要删除 snapshot 并重新激活:
1
2 rm ~/.pub-cache/global_packages/patrol_cli/bin/main.dart-3.11.0.snapshot
dart pub global activate patrol_cli 3.11.0
问题 5: 模拟器被克隆
测试运行时,xcodebuild 为每个 test suite 创建了独立的模拟器克隆(“Clone 1 of iPhone 16e”),导致启动多个模拟器实例。
原因: Runner.xcscheme 中 TestableReference 的 parallelizable 属性:
1 | <!-- 修改前 --> |
问题 6: pressBack() 在 iOS 上不可用
Android 测试中广泛使用的 $.native.pressBack() 在 iOS 上返回 400 错误:
1 | pressBack() failed with Invalid response: 400 |
iOS 没有系统级返回按钮,这是 Android 独有的功能。
解决方案: 创建跨平台 goBack() helper:
1 | Future<void> goBack(PatrolIntegrationTester $) async { |
踩坑细节: iOS 上 Flutter AppBar 的返回图标是
Icons.arrow_back_ios_new_rounded(不是arrow_back也不是arrow_back_ios)。直接用 Patrol 的$(Icons.xxx).tap()找到图标后会报 “not hit-testable”,因为图标嵌套在 AppBar 的约束区域内。$.tester.pageBack()是最可靠的方案。
3. 解决方案
步骤一:修改 Xcode 项目 target 配置
在 project.pbxproj 中:
- 将
productType从unit-test改为ui-testing - 删除
BUNDLE_LOADER和TEST_HOST - 添加
TEST_TARGET_NAME = Runner - 全局重命名
RunnerTests→RunnerUITests - 添加
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"
步骤二:创建 Patrol iOS 测试入口
1 | // ios/RunnerUITests/RunnerUITests.m |
步骤三:更新 Podfile
1 | target 'Runner' do |
步骤四:修复 Runner.xcscheme
1 | <!-- BuildAction 中必须包含 RunnerUITests --> |
步骤五:修补 patrol_cli 本地源码
1 | // ~/.pub-cache/hosted/.../patrol_cli-3.11.0/lib/src/crossplatform/app_options.dart |
1 | # 删除 snapshot 使修改生效 |
步骤六:跨平台返回导航 helper
1 | // integration_test/helpers/patrol_helper.dart |
所有测试文件中的 $.native.pressBack() 替换为 goBack($)。
步骤七:更新 Makefile
1 |
|
4. 预防与建议
- Patrol iOS 的 target 必须叫
RunnerUITests:这是 patrol_cli 硬编码的名称,不是可配置的。新项目请一开始就用这个名字。 productType必须是ui-testing:Unit Test Bundle 和 UI Testing Bundle 是完全不同的东西,Patrol 需要后者。inherit! :complete而非:search_paths:UI Testing Bundle 在独立进程运行,需要完整嵌入所有框架。- Patrol CLI 的 snapshot 机制:修改 patrol_cli 源码后,必须删除
.snapshot文件并重新激活,否则修改不会生效。 pressBack()是 Android 独有:iOS 测试中必须用$.tester.pageBack()或手动点击 UI 中的返回按钮。- 模拟器管理:测试前先
xcrun simctl shutdown all确保只有一个模拟器运行,避免克隆。 - AlertDialog 关闭:iOS 上不能用
goBack()关闭 AlertDialog(没有系统返回键),必须点击对话框中的按钮。
5. 最终成果
iOS 测试结果
| 模块 | 测试内容 | 状态 | 耗时 |
|---|---|---|---|
| Dashboard | 启动、搜索、筛选、添加物品、ChoiceChip 切换、详情页 | 通过 | 44s |
| Inventory | 添加、验证、详情、消耗、删除、编辑、高级详情、连续添加 | 通过 | 63s |
| Settings | 数据管理、分类、位置、语言切换、数据统计、反馈对话框 | 通过 | 43s |
| History | 空状态、消耗后出现、补货流程、多物品历史 | 通过 | 45s |
问题解决链路图
1 | xcodebuild exit code 70 (Total: 0) |
运行命令
1 | make ios-boot # 启动 iOS 模拟器 |