一、定位
本文是 为大型 iOS 工程补充单元测试方法论 (基础框架)、如何测试不愿暴露的私有状态(私有状态测试)、变更驱动测试与跨模块 Mock(进阶模式)的第三篇补充。
前三篇分别解决了"测什么"、"怎么测私有状态"、"怎么测复杂依赖"的问题。本文聚焦于一个更基础但常被忽视的问题:当测试代码用 Swift 编写、而被测系统的依赖大量暴露为 Objective-C 协议时,Mock 对象的创建本身就成了一场工程挑战。
在大型混合语言 iOS 工程中,这不是偶发问题,而是常态——几乎每一个有意义的业务模块都依赖跨语言的协议接口。本文将实践中反复踩过的坑提炼为可复用的策略,核心围绕五个问题展开:
- 当 ObjC 协议要求"全量实现"时,轻量 Mock 思路为何失效?
- 跨语言类型映射有哪些系统性的陷阱?
- 何时该放弃运行时反射,转向更务实的策略?
- 如何避免"按文档写测试"导致的假设错误?
- 编译-失败-修复的迭代循环是一种方法论,而非失败的信号。
二、全量协议 Mock:当"只实现需要的方法"行不通
2.1 问题
前文的 Mock 策略建议:"仅实现测试需要的方法,非必需方法留空实现。"这条建议对 Swift 原生协议或标记了 @objc optional 的协议完全成立。但在混合语言工程中,大量核心协议来自 Objective-C,它们的所有属性和方法默认都是 required。
当一个 ObjC 协议声明了 18 个属性——每个都是 required——你的 Swift Mock 类必须实现全部 18 个,哪怕你的测试只关心其中 2 个。编译器不会给你任何妥协的余地。
2.2 原则:分离"骨架"与"控制面"
面对全量协议 Mock,核心策略是在心智上将属性分为两类:
| 类别 | 定义 | 处理方式 |
|---|---|---|
| 骨架属性 | 协议要求但测试不关心的属性 | 赋予安全的默认值,写完后不再碰 |
| 控制面属性 | 测试需要变化的属性 | 通过 init 参数或 var 暴露给测试 |
private class MockFilterItem: NSObject, SomeObjCProtocol {
// ── 控制面:测试会操作这些 ──
@objc dynamic var identifier: String
@objc dynamic var unreadCountCalculateEnable: Bool
init(identifier: String, unreadCountCalculateEnable: Bool = true) {
self.identifier = identifier
self.unreadCountCalculateEnable = unreadCountCalculateEnable
super.init()
}
// ── 骨架:编译需要,测试不碰 ──
@objc dynamic var trackName: String = ""
@objc dynamic var isSelected: Bool = false
// ... 其余 14 个属性 ...
}
这种分离带来两个好处:
- 可读性:阅读测试代码的人立刻知道哪些属性与测试逻辑相关——它们出现在
init签名中。 - 可维护性:当协议新增属性时,只需在骨架区域添加一行默认值,不影响已有测试。
2.3 反模式:逐个测试补属性
一个常见的反模式是:先写一个只有 3 个属性的 Mock,编译失败后加 1 个,再编译再失败再加……如此反复十几轮。这种"打地鼠"式的修复效率极低,且容易在类型细节上反复犯错。
更好的做法是一次性通读协议定义,列出全部属性,一次性构建完整的 Mock。前期多花 10 分钟通读协议,远好过后期花 1 小时反复编译。
三、跨语言类型映射:一份实用速查表
3.1 问题
当 Swift Mock 类实现 ObjC 协议时,每个属性的类型都需要正确映射。Swift 编译器对类型匹配极为严格——即使语义相同,写法不对也无法编译。这种映射错误是全量 Mock 中最频繁的失败原因。
3.2 速查表
| ObjC 声明 | Swift Mock 中的正确写法 | 常见错误写法 | 错误原因 |
|---|---|---|---|
@property NSString *name | @objc dynamic var name: String | var name: String | 缺少 @objc dynamic,ObjC 侧无法识别 |
@property (nullable) id<SomeProtocol> delegate | @objc dynamic var delegate: (any SomeProtocol)? | var delegate: SomeProtocol? | 缺少 any 存在类型标记(Swift 5.6+) |
@property SomeObjCEnum action | @objc dynamic var action: SomeObjCEnum = .defaultValue | var action: Int = 0 | 用 Int 代替枚举类型不满足协议要求 |
@property (copy) void(^block)(SomeEnum) | @objc dynamic var block: ((SomeEnum) -> Bool) = { _ in true } | var block: ((Int) -> Bool) | 闭包参数类型必须匹配 ObjC 枚举的 Swift 映射 |
@property (nullable) SomeSDKClass *query | @objc dynamic var query: SomeSDKClass? | 编译报错 cannot find type | 需要额外 import SomeSDK |
3.3 三条核心规则
规则一:所有属性必须标记 @objc dynamic。 ObjC 协议的属性在运行时通过 ObjC 消息分发访问。如果缺少 @objc dynamic,Swift 编译器可能将属性编译为 Swift-native 存储,导致 ObjC 侧调用时找不到对应的 selector。
规则二:Mock 类必须继承 NSObject,而非声明遵守 NSObjectProtocol。 Swift 不允许直接声明对 NSObjectProtocol 的遵守——这是 ObjC 运行时的根协议,必须通过继承 NSObject 来满足。这是一个非直觉的编译器限制,错误信息明确但容易被忽略:
cannot declare conformance to 'NSObjectProtocol' in Swift;
'MockClass' should inherit 'NSObject' instead
规则三:协议属性引用的 SDK 类型需要显式导入。 当协议中出现 TIMOConversationListQuery 或 IESIMConversationProtocol 等类型时,你的测试文件必须 import 这些类型所在的模块。编译器不会因为你 @testable import 了被测模块就自动传递它的依赖。
3.4 类型映射的发现方法
遇到 does not conform to protocol 错误时,不要猜测类型——直接去读协议定义。在混合语言工程中,ObjC 协议的 .h 文件是唯一的权威来源。具体步骤:
- 在代码库中搜索协议名(如
IMInboxFilterItemModelProtocol) - 打开
.h文件,逐行比对每个@property声明 - 对每个属性,确认:类型、是否 nullable、是否 readonly
- 在 Swift 侧写出对应声明,注意存在类型(
any)和可选类型(?)
四、反射的边界:何时放弃,如何转向
4.1 问题
前文介绍了通过 ObjC Runtime 访问私有状态的方法(ivar 注入、方法替换等)。这些技术对 NSObject 子类的实例方法非常有效。但当目标是一个 Swift 实现的 private 方法时,反射往往会走入死胡同。
典型的失败链路:
尝试 perform(#selector) → Swift private 方法无 ObjC selector
尝试获取 IMP 并调用 → IMP 是 OpaquePointer,无法直接转为函数指针
尝试 method_invoke → 参数和返回值的类型编码不匹配
每一步都"看起来差一点就能成功",但实际上越陷越深。
4.2 决策原则:30 分钟规则
当通过反射测试一个私有方法时,如果 30 分钟内无法编译通过,应立即停下来重新评估策略。反射的价值在于"绕过封装以降低测试成本"——如果绕过本身的成本已经超过了测试的价值,就说明方向错了。
4.3 转向策略:从"直接调用"到"行为观测"
放弃反射后,替代策略是通过公有 API 驱动被测对象,间接触发私有方法的执行,然后观测可见的状态变化。
以测试一个 Cell 的私有 getUnreadCount() 方法为例:
失败路径:
直接调用 cell.getUnreadCount() → 方法是 private → 尝试反射 → 失败
成功路径:
构造 model (控制输入)
→ 调用 cell.configWithModel(model) (公有 API)
→ configWithModel 内部会调用 getUnreadCount()
→ 观测 cell 的可见状态 (UI badge / 属性值)
这种转向看似"测得不够直接",但实际上更贴近真实使用场景。私有方法之所以是私有的,说明它只应通过特定的公有入口被触发。测试公有入口的行为,比测试私有方法的返回值,更能保护真正的业务语义。
4.4 判断矩阵
| 目标方法特征 | 推荐策略 | 原因 |
|---|---|---|
ObjC @objc 方法 | Runtime 反射可行 | 有完整的 ObjC 元数据 |
Swift internal 方法 | @testable import 直接调用 | 最简单 |
Swift private 方法,有公有调用链 | 通过公有 API 间接测试 | 稳定、不依赖实现细节 |
Swift private 方法,无公有调用链 | 推动重构(提取为 internal 或注入) | 说明封装设计本身有问题 |
五、以代码为准:当假设与实现不一致
5.1 问题
编写测试时,开发者通常基于对业务逻辑的"理解"来设定预期值。这种理解可能来自文档、口头沟通、变量命名暗示,或对类似代码模式的类推。但这些来源都可能与实际代码不一致。
一个典型案例:某个方法处理多种类型的入口(normal、group、messageRequest、strangerSpam 等)。从命名和文档来看,"spam entrance"不应该贡献未读计数。基于这个假设,测试断言其返回值为 0。然而实际代码中,该方法通过 isSpamEntrance 属性判断——而这个属性将 spam entrance 归入允许列表,返回的是实际的未读数。
假设: strangerSpamEntrance → 不计未读 → 返回 0
现实: isSpamEntrance = true → 命中允许列表 → 返回 unreadCount
测试执行失败,不是因为代码有 bug,而是因为测试本身的假设就是错的。
5.2 原则:先读实现,后写断言
这条原则看似显而易见,但在实践中极易被违反——尤其是当开发者自认为"已经理解了逻辑"的时候。推荐的工作流是:
1. 定位被测方法的源码
2. 逐行阅读实现,特别注意:
- 计算属性和间接属性(如 isSpamEntrance 并非直接判断 type == spam)
- 条件组合中的 OR/AND 关系
- 早返回(early return)的条件
3. 基于代码实现(而非文档描述)编写断言
4. 如果实现与文档矛盾,以代码为准,并在断言消息中记录差异
5.3 间接属性的特殊风险
上述案例中的 isSpamEntrance 是一个计算属性,它封装了 type == strangerSpamEntrance || type == strangerReactionsEntrance 的判断。被测方法使用的是 isSpamEntrance 而非直接比较 type。这意味着:
- 仅阅读被测方法的代码不够——你还需要跟进计算属性的实现
- 变量的命名可能暗示与实际逻辑不同的语义("spam entrance"听起来像应该被排除,但实际上被包含)
建议:遇到布尔属性作为条件判断时,总是查看其实现,不要仅凭命名推测其含义。
5.4 Assertion Message 作为知识载体
当测试验证了一个"反直觉"的行为时,assertion message 应当成为解释的载体:
// 不好:只说明了结果
XCTAssertEqual(result, 10, "Should return 10")
// 好:解释了为什么结果是这样
XCTAssertEqual(result, 10,
"Spam entrance is included via isSpamEntrance check, should return actual unreadCount")
这条消息不仅在测试失败时有用——它也是未来开发者阅读测试代码时理解业务语义的入口。
六、迭代收敛:将编译循环转化为系统方法
6.1 问题
在创建全量 ObjC 协议 Mock 的过程中,经历多轮"编译 → 失败 → 修复"是常态而非例外。但如果每一轮都是盲目地"哪里报错改哪里",效率会极低,且容易引入新错误。
6.2 方法:分层收敛
将编译错误按层级分类,从最基础的问题开始修复,每修一层,上层错误可能自动消失:
Layer 0: 导入缺失 (import SomeSDK)
↓ 修复后,部分 "cannot find type" 错误消失
Layer 1: 继承关系 (NSObject 继承 vs NSObjectProtocol 声明)
↓ 修复后,"cannot declare conformance" 错误消失
Layer 2: 属性声明 (@objc dynamic + 正确类型)
↓ 修复后,"does not conform to protocol" 错误消失
Layer 3: 属性默认值 (枚举类型需要有效的默认 case)
↓ 修复后,编译通过
关键:不要试图在 Layer 0 未解决时去修 Layer 2 的问题。底层的 import 缺失可能导致上层出现大量误导性的错误信息。
6.3 一次性通读 vs 逐个修复
| 策略 | 适用场景 | 风险 |
|---|---|---|
| 一次性通读协议定义 | 首次为一个协议创建 Mock | 前期投入时间,但总时间更短 |
| 逐个修复编译错误 | Mock 已大体完成,只差 1-2 个属性 | 可能陷入"打地鼠"循环 |
推荐默认使用一次性通读策略。只有当你确信 Mock 已经"差不多完成"时,才切换到逐个修复。
6.4 跨文件 Mock 复用
当同一个协议需要在多个测试文件中被 Mock 时(例如 IMInboxFilterItemModelProtocol 同时在 DMInboxFilterBarCellTests 和 DMInboxFilterSectionViewModelTests 中需要),有两种策略:
| 策略 | 优点 | 缺点 |
|---|---|---|
| 每个文件独立 Mock | 各文件独立,修改互不影响 | 协议变更时需要修改多处 |
| 提取共享 Mock 文件 | 单点维护 | 引入文件间耦合,需要管理访问级别 |
建议:当 Mock 出现在 3 个以上文件时,提取为共享文件。2 个以下时,容忍复制,保持独立性。在共享 Mock 文件中,将类声明为 internal(而非 private),并放在测试 target 的公共路径下。
七、总结:五条行动指南
| # | 指南 | 核心思想 |
|---|---|---|
| 1 | 一次性通读协议,构建完整 Mock | 不要逐个属性地"打地鼠",前期的通读投资回报率最高 |
| 2 | @objc dynamic + NSObject 继承是标配 | 这不是可选的修饰——是 ObjC 协议在 Swift 中正确工作的前提 |
| 3 | 30 分钟内反射不通,立刻转向公有 API 测试 | 反射是手段不是目的,沉没成本不是继续的理由 |
| 4 | 断言基于代码实现,不基于文档或命名推测 | 计算属性、间接逻辑、命名误导都可能让"直觉"出错 |
| 5 | 编译错误分层修复:import → 继承 → 类型 → 默认值 | 底层问题会制造大量误导性的上层错误,从根源开始 |
这五条指南与前三篇文档的方法论互补。前三篇解决了"测什么"、"怎么绕过封装"、"怎么处理复杂依赖"的问题。本文解决的是更前置的工程问题:在混合语言环境中,如何让你的 Mock 代码先编译通过——然后才谈得上测试逻辑是否正确。