跨越 ObjC-Swift 边界:混合语言工程中的单元测试实战策略

4 阅读12分钟

一、定位

本文是 为大型 iOS 工程补充单元测试方法论 (基础框架)、如何测试不愿暴露的私有状态(私有状态测试)、变更驱动测试与跨模块 Mock(进阶模式)的第三篇补充

前三篇分别解决了"测什么"、"怎么测私有状态"、"怎么测复杂依赖"的问题。本文聚焦于一个更基础但常被忽视的问题:当测试代码用 Swift 编写、而被测系统的依赖大量暴露为 Objective-C 协议时,Mock 对象的创建本身就成了一场工程挑战。

在大型混合语言 iOS 工程中,这不是偶发问题,而是常态——几乎每一个有意义的业务模块都依赖跨语言的协议接口。本文将实践中反复踩过的坑提炼为可复用的策略,核心围绕五个问题展开:

  1. 当 ObjC 协议要求"全量实现"时,轻量 Mock 思路为何失效?
  2. 跨语言类型映射有哪些系统性的陷阱?
  3. 何时该放弃运行时反射,转向更务实的策略?
  4. 如何避免"按文档写测试"导致的假设错误?
  5. 编译-失败-修复的迭代循环是一种方法论,而非失败的信号。

二、全量协议 Mock:当"只实现需要的方法"行不通

2.1 问题

前文的 Mock 策略建议:"仅实现测试需要的方法,非必需方法留空实现。"这条建议对 Swift 原生协议或标记了 @objc optional 的协议完全成立。但在混合语言工程中,大量核心协议来自 Objective-C,它们的所有属性和方法默认都是 required

当一个 ObjC 协议声明了 18 个属性——每个都是 required——你的 Swift Mock 类必须实现全部 18 个,哪怕你的测试只关心其中 2 个。编译器不会给你任何妥协的余地。

2.2 原则:分离"骨架"与"控制面"

面对全量协议 Mock,核心策略是在心智上将属性分为两类:

类别定义处理方式
骨架属性协议要求但测试不关心的属性赋予安全的默认值,写完后不再碰
控制面属性测试需要变化的属性通过 init 参数或 var 暴露给测试
private class MockFilterItemNSObjectSomeObjCProtocol {
    // ── 控制面:测试会操作这些 ──
    @objc dynamic var identifier: String
    @objc dynamic var unreadCountCalculateEnable: Bool

    init(identifierStringunreadCountCalculateEnableBool = true) {
        self.identifier = identifier
        self.unreadCountCalculateEnable = unreadCountCalculateEnable
        super.init()
    }

    // ── 骨架:编译需要,测试不碰 ──
    @objc dynamic var trackName: String = ""
    @objc dynamic var isSelected: Bool = false
    // ... 其余 14 个属性 ...
}

这种分离带来两个好处:

  1. 可读性:阅读测试代码的人立刻知道哪些属性与测试逻辑相关——它们出现在 init 签名中。
  2. 可维护性:当协议新增属性时,只需在骨架区域添加一行默认值,不影响已有测试。

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: Stringvar 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 = .defaultValuevar 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 文件是唯一的权威来源。具体步骤:

  1. 在代码库中搜索协议名(如 IMInboxFilterItemModelProtocol
  2. 打开 .h 文件,逐行比对每个 @property 声明
  3. 对每个属性,确认:类型、是否 nullable、是否 readonly
  4. 在 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 中正确工作的前提
330 分钟内反射不通,立刻转向公有 API 测试反射是手段不是目的,沉没成本不是继续的理由
4断言基于代码实现,不基于文档或命名推测计算属性、间接逻辑、命名误导都可能让"直觉"出错
5编译错误分层修复:import → 继承 → 类型 → 默认值底层问题会制造大量误导性的上层错误,从根源开始

这五条指南与前三篇文档的方法论互补。前三篇解决了"测什么"、"怎么绕过封装"、"怎么处理复杂依赖"的问题。本文解决的是更前置的工程问题:在混合语言环境中,如何让你的 Mock 代码先编译通过——然后才谈得上测试逻辑是否正确。