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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 问题 1: xcodebuild 退出码 70,测试数量为 0
Test summary:
Total: 0
Successful: 0
Failed to execute tests (xcodebuild exited with code 70)

# 问题 2: 无法找到模拟器设备
xcodebuild: error: Unable to find a device matching the provided destination specifier
Supported platforms for the buildables in the current scheme is empty.

# 问题 3: dylib 加载失败
Library not loaded: CocoaAsyncSocket.framework/CocoaAsyncSocket

# 问题 4: pressBack 在 iOS 上不可用
PatrolActionException: pressBack() failed with Invalid response: 400
method pressBack() is not implemented on iOS

# 问题 5: 模拟器被克隆
Test case started on 'Clone 1 of iPhone 16e - RunnerUITests-Runner'

1. 现象描述与现场还原

从 Android 到 iOS

Android E2E 测试全部通过后,执行 patrol test -d "iPhone 16e" 启动 iOS 测试。预期:像 Android 一样直接跑起来。实际:xcodebuild exited with code 70Total: 0,测试完全没有被发现。

排查过程时间线

整个调试过程跨越了多个问题层级,每修复一个问题就暴露下一个:

  1. Unit Test vs UI Test — 测试 target 类型不对
  2. 目标命名 — Patrol CLI 硬编码期望 RunnerUITests,不是 RunnerTests
  3. 框架嵌入inherit! :search_paths 不够,需要 :complete
  4. 设备匹配-destination-timeout 1 太短 + SUPPORTED_PLATFORMS 不全
  5. 模拟器克隆parallelizable="YES" 导致克隆
  6. 返回导航pressBack() 在 iOS 上不存在

2. 根本原因分析

问题 1: RunnerTests 是 Unit Test Bundle,不是 UI Testing Bundle

Flutter 默认创建的 RunnerTests target 的 productTypecom.apple.product-type.bundle.unit-test。Patrol 需要的是 com.apple.product-type.bundle.ui-testing

差异对比:

设置Unit Test BundleUI Testing Bundle (Patrol)
productType.bundle.unit-test.bundle.ui-testing
TEST_HOST需要设置不需要
BUNDLE_LOADER需要设置不需要
TEST_TARGET_NAME不需要需要 = Runner
Podfile 继承inherit! :search_pathsinherit! :complete

修改 project.pbxproj

1
2
- productType = "com.apple.product-type.bundle.unit-test";
+ productType = "com.apple.product-type.bundle.ui-testing";

同时删除 BUNDLE_LOADERTEST_HOST,添加 TEST_TARGET_NAME = Runner

问题 2: Patrol CLI 期望 RunnerUITests,而非 RunnerTests

深入 Patrol CLI 源码 (app_options.dart) 发现:

1
2
// patrol_cli 源码中硬编码
...['-only-testing', 'RunnerUITests/RunnerUITests'],

无论你的 Xcode 项目中 target 叫什么名字,Patrol CLI 都会固定查找 RunnerUITests

解决方案: 全面重命名:

  • 目录:ios/RunnerTests/ios/RunnerUITests/
  • 文件:RunnerTests.swiftRunnerUITests.m(改用 Objective-C + Patrol 宏)
  • project.pbxproj 中所有引用
  • Podfile 中 target 名称

创建 Objective-C 测试入口:

1
2
3
4
5
6
// ios/RunnerUITests/RunnerUITests.m
@import XCTest;
@import patrol;
@import ObjectiveC.runtime;

PATROL_INTEGRATION_TEST_IOS_RUNNER(RunnerUITests)

问题 3: CocoaAsyncSocket 框架加载失败

1
Library not loaded: CocoaAsyncSocket.framework/CocoaAsyncSocket

UI Testing Bundle 运行在独立进程中,需要完整嵌入所有依赖框架。

修改 Podfile:

1
2
3
4
5
6
7
8
9
# 修改前
target 'RunnerUITests' do
inherit! :search_paths
end

# 修改后
target 'RunnerUITests' do
inherit! :complete
end

问题 4: 设备匹配失败 — destination-timeout 与 SUPPORTED_PLATFORMS

即使 target 配置正确,xcodebuild test-without-building 仍然报:

1
2
Supported platforms for the buildables in the current scheme is empty.
Unable to find a device matching the provided destination specifier

两层原因:

4a. SUPPORTED_PLATFORMS 不完整

project.pbxproj 中项目级别的 Release/Profile 配置:

1
2
3
- SUPPORTED_PLATFORMS = iphoneos;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;

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
2
3
4
5
6
7
8
9
// ~/.pub-cache/hosted/.../patrol_cli-3.11.0/lib/src/crossplatform/app_options.dart

// 修改前
...['-destination', 'platform=${...},name=${device.name}'],
...['-destination-timeout', '1'],

// 修改后
...['-destination', 'id=${device.id}'],
...['-destination-timeout', '30'],

注意: 修改源码后需要删除 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.xcschemeTestableReferenceparallelizable 属性:

1
2
3
4
5
<!-- 修改前 -->
<TestableReference skipped="NO" parallelizable="YES">

<!-- 修改后 -->
<TestableReference skipped="NO" parallelizable="NO">

问题 6: pressBack() 在 iOS 上不可用

Android 测试中广泛使用的 $.native.pressBack() 在 iOS 上返回 400 错误:

1
2
pressBack() failed with Invalid response: 400
method pressBack() is not implemented on iOS

iOS 没有系统级返回按钮,这是 Android 独有的功能。

解决方案: 创建跨平台 goBack() helper:

1
2
3
4
5
6
7
8
9
Future<void> goBack(PatrolIntegrationTester $) async {
if (Platform.isAndroid) {
await $.native.pressBack();
} else {
// Flutter 内置方法,正确处理 Material/Cupertino 返回按钮
await $.tester.pageBack();
}
await $.pumpAndSettle();
}

踩坑细节: 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 中:

  1. productTypeunit-test 改为 ui-testing
  2. 删除 BUNDLE_LOADERTEST_HOST
  3. 添加 TEST_TARGET_NAME = Runner
  4. 全局重命名 RunnerTestsRunnerUITests
  5. 添加 SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"

步骤二:创建 Patrol iOS 测试入口

1
2
3
4
5
6
// ios/RunnerUITests/RunnerUITests.m
@import XCTest;
@import patrol;
@import ObjectiveC.runtime;

PATROL_INTEGRATION_TEST_IOS_RUNNER(RunnerUITests)

步骤三:更新 Podfile

1
2
3
4
5
6
7
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerUITests' do
inherit! :complete
end
end

步骤四:修复 Runner.xcscheme

1
2
3
4
5
6
7
8
9
10
11
12
<!-- BuildAction 中必须包含 RunnerUITests -->
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" ...>
<BuildableReference
BlueprintIdentifier="331C8080294A63A400263BE5"
BuildableName="RunnerUITests.xctest"
BlueprintName="RunnerUITests"
ReferencedContainer="container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>

<!-- TestableReference 禁用并行化 -->
<TestableReference skipped="NO" parallelizable="NO">

步骤五:修补 patrol_cli 本地源码

1
2
3
4
// ~/.pub-cache/hosted/.../patrol_cli-3.11.0/lib/src/crossplatform/app_options.dart
// 用设备 UUID 直接定位,绕过平台解析问题
...['-destination', 'id=${device.id}'],
...['-destination-timeout', '30'],
1
2
3
# 删除 snapshot 使修改生效
rm ~/.pub-cache/global_packages/patrol_cli/bin/main.dart-3.11.0.snapshot
dart pub global activate patrol_cli 3.11.0

步骤六:跨平台返回导航 helper

1
2
3
4
5
6
7
8
9
10
11
// integration_test/helpers/patrol_helper.dart
import 'dart:io' show Platform;

Future<void> goBack(PatrolIntegrationTester $) async {
if (Platform.isAndroid) {
await $.native.pressBack();
} else {
await $.tester.pageBack();
}
await $.pumpAndSettle();
}

所有测试文件中的 $.native.pressBack() 替换为 goBack($)

步骤七:更新 Makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.PHONY: ios-boot
ios-boot:
xcrun simctl shutdown all || true
sleep 3
xcrun simctl boot "iPhone 16e"
open -a Simulator
sleep 10

.PHONY: e2e-ios
e2e-ios: ios-boot
$(PATROL) test -d "iPhone 16e" -t integration_test/modules/dashboard_test.dart
$(PATROL) test -d "iPhone 16e" -t integration_test/modules/inventory_test.dart
$(PATROL) test -d "iPhone 16e" -t integration_test/modules/settings_test.dart
$(PATROL) test -d "iPhone 16e" -t integration_test/modules/history_test.dart

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
2
3
4
5
6
7
8
9
10
xcodebuild exit code 70 (Total: 0)
└─ productType = unit-test → 改为 ui-testing
└─ target 名 RunnerTests → 改为 RunnerUITests
└─ BUNDLE_LOADER/TEST_HOST → 删除,加 TEST_TARGET_NAME
└─ inherit! :search_paths → 改为 :complete
└─ "Supported platforms is empty" → 加 iphonesimulator 到 SUPPORTED_PLATFORMS
└─ destination-timeout 1 + name 匹配 → 改用 device.id + timeout 30
└─ parallelizable="YES" → 改为 "NO"(防克隆)
└─ pressBack() not implemented → 用 $.tester.pageBack()
└─ arrow_back_ios_new_rounded not hit-testable → 用 pageBack() 而非 icon tap

运行命令

1
2
3
4
5
make ios-boot        # 启动 iOS 模拟器
make e2e-ios # 全部模块 (iOS)
make e2e-android # 全部模块 (Android)
make e2e-dashboard # 仅 Dashboard
make e2e-settings # 仅 Settings