一、背景与目标
在拥有数百个模块、数万乃至数十万个源文件的大型 iOS 工程中,业务逻辑往往跨越多层、多模块,端到端链路很长。"某App的会话列表页红点未读数"就是一个典型场景:它从 IM SDK 的底层回调出发,经过多层聚合、多个实验开关、多种显示策略,最终汇聚到 TabBar 上的一个数字。任何一环出错,用户看到的未读数就会异常。
本文以该功能为切入点,提炼出一套可复用的方法论,指导在大型工程中识别关键链路 → 设计高价值测试 → 落地到持续迭代的完整过程。
二、方法论概览
为一个已有的复杂功能补充单测,推荐按以下四步推进:
Step 1: 画链路 → 理解数据从哪来、到哪去、经过哪些节点
Step 2: 选节点 → 从链路中筛选出最值得测试的方法
Step 3: 写测试 → 针对每个节点设计测试用例并解决 Mock 问题
Step 4: 融入迭代 → 让测试在日常开发中真正发挥价值
三、Step 1:画出关键链路
3.1 为什么要先画链路
大型工程中的功能通常不是一个类就能完成的。如果不先建立全局视图,容易陷入"只测了一角、漏了关键节点"的困境。画链路的目的是:
-
看清数据流向:输入从哪里来,最终输出到哪里
-
识别分叉点:哪些地方有实验开关或架构分支
-
发现聚合点:哪些地方汇总了多路数据
3.2 案例:"Inbox 页未读数"链路
通过阅读文档和代码,梳理出以下数据流:
┌───────────────────────────────────────────────────────────────────────┐
│ 数据源层 │
│ │
│ Non-Pagination 架构: Pagination 架构: │
│ IESIMChatDataManager (SDK回调) IMConversationDataSource │
│ ↓ ↓ │
│ IMChatDataController UnreadConversationInfoDataSource │
│ ↓ ↓ │
│ IMUnreadCountDataSource UnreadConversationInfoManager │
│ (unreadCountMap / redDotCountMap) (dataContextMap 聚合) │
│ ↓ ↓ │
│ └──── countForNumber: ────┬──── countForNumber: ────┘ │
│ countForUnreadCell: │ countForUnreadCell: │
│ ↓ │
│ NoticeUnreadInjectItemInteractor │
│ (遍历 biz items, 按 showType 求和) │
│ ↓ │
│ BizScenarioItemInboxTabNumber │
│ (+ Notice 组 count, - Shop 210/214) │
│ → noticeTabbarUnreadCount (消息数) │
│ → noticeTabbarUnreadCellCount (会话数) │
│ ↓ │
│ TabBarInboxItem │
│ (缓存 / 气泡 / 最终 displayCount) │
└───────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Cell 显示层 │
│ │
│ IMChatListCellUnreadUtil │
│ ├── canShowUnreadCountWithModel: (显示数字?) │
│ ├── canShowUnreadDotWithModel: (显示红点?) │
│ └── canShowUnreadIndicatorWithModel: (是否未读?) │
│ ↑ 依赖 │
│ InboxCellRevampConfig │
│ (shouldShowRedDotForUnread / shouldChangeNumToDotForTab / ...) │
└─────────────────────────────────────────────────────────────────────┘
3.3 链路梳理技巧
| 技巧 | 说明 |
|------|------|
| 从输出倒推 | 从用户可见的 TabBar 角标开始,逐层向上追溯数据来源 |
| 标注分叉 | 用 isInboxPaginationEnable 等开关标记架构分支 |
| 区分聚合点和透传点 | 聚合点(如 BizScenarioItemInboxTabNumber)比透传点更值得测试 |
| 借助已有文档 | 模块的 CLAUDE.md、workflow.md 等文档能大幅加速理解 |
四、Step 2:筛选最值得测试的方法
4.1 筛选标准
不是所有方法都值得写单测。在资源有限时,优先覆盖符合以下特征的方法:
| 优先级 | 特征 | 说明 |
|--------|------|------|
| P0 | 逻辑分支多 | 方法内有 3 个以上 if 分支,尤其是分支间有优先级或互斥关系 |
| P0 | 聚合计算 | 方法将多路数据汇总为一个结果,任一输入错误即影响最终输出 |
| P1 | 实验开关控制 | 方法行为随 AB 实验变化,不同实验组走完全不同的路径 |
| P1 | 边界条件敏感 | 涉及时间比较、数值阈值、nil 处理等边界场景 |
| P2 | 数据过滤 | 方法包含 subset/mute/excluded 等过滤逻辑 |
4.2 案例:5 个优先级最高的方法
基于上述标准,从链路中筛选出 5 个最应优先覆盖的方法:
| # | 方法 | 所属类 | 入选理由 |
|---|------|--------|----------|
| 1 | canShowUnreadCountWithModel: | IMChatListCellUnreadUtil | 6 个分支条件,含实验开关、时间过期、类型豁免 |
| 2 | canShowUnreadDotWithModel: | IMChatListCellUnreadUtil | 与方法 1 互补但优先级不同,容易引入回归 bug |
| 3 | inboxUnreadCount() / inboxUnreadConversationCount() | UnreadConversationInfoManager | 核心聚合点,subset 过滤逻辑一旦出错影响全局 |
| 4 | countForNumber: / countForUnreadCell: | NoticeUnreadInjectItemInteractor | 按 showType 位掩码求和,是 DM 与 Notice 的汇聚口 |
| 5 | checkHasNoticeTabbarUnreadCount:interactor:reservedInfo: | BizScenarioItemInboxTabNumber | 最终聚合节点,含 Shop 组排除等特殊逻辑 |
五、Step 3:设计与编写测试用例
5.1 测试设计原则
5.1.1 三维覆盖法
针对每个待测方法,从三个维度设计用例:
正常路径 (Happy Path) → 标准输入,验证预期输出
异常路径 (Edge Cases) → nil、0、负数、空集合等边界输入
实验分组 (Experiment) → 不同 AB 实验组下的行为差异
5.1.2 每个分支至少一条用例
对于有多个 if 分支的方法,确保每个分支至少被一个用例覆盖。例如 canShowUnreadCountWithModel: 有 6 个 return NO 的分支,至少需要 6 + 1(happy path)= 7 条用例。
5.2 案例:各核心方法的测试方案
方法 1 & 2:IMChatListCellUnreadUtil
canShowUnreadCountWithModel:
├── 正常路径: 普通会话 + 有未读 + 实验关 → 显示数字 ✓
├── mute 分支: mute 普通会话 → 不显示 ✓ / mute Group → 仍显示 ✓ (类型豁免)
├── 未读数为 0: → 不显示 ✓
├── 红点实验开: → 不显示数字 ✓ (走红点)
├── 过期窗口内: shouldChangeNumToDotForTab=true, 更新时间 < 7天 → 显示数字 ✓
├── 过期窗口外: shouldChangeNumToDotForTab=true, 更新时间 > 7天 → 不显示 ✓ (降级为红点)
├── 过期天数=0: 视为实验关, 不触发降级 ✓
└── nil model: → 不显示 ✓
canShowUnreadDotWithModel:
├── 红点实验开 + 有未读: → 显示红点 ✓ (即使 muted)
├── 红点实验关 + muted: → 不显示红点 ✓
├── 过期窗口外: → 显示红点 ✓ (数字降级为红点)
└── 普通会话 + 有未读 + 红点关: → 不显示红点 ✓ (走数字)
canShowUnreadIndicatorWithModel:
└── 验证恒等式: indicator = showCount || showDot
方法 3:UnreadConversationInfoManager
inboxUnreadCount() / inboxUnreadConversationCount():
├── 空 dataSource: → 返回 0
├── 单个非 subset 源: → 返回该源的 count
├── 多个源: → 求和
├── 含 subset 源: → subset 源不计入总数
├── 全部为 subset: → 返回 0
├── enableUnreadCountCalculate=false: → 不创建 dataSource
└── 消息数 vs 会话数独立性: 两个方法读取不同字段
方法 4:NoticeUnreadInjectItemInteractor
countForNumber: / countForUnreadCell:
├── 无 biz item: → 返回 0
├── 单个 Number 类型 item: → 返回其 count
├── 多个 item 求和: → 总计
├── Dot 类型 item: → 跳过, 不计入
├── Number|Bubble 组合类型: → Number 位匹配, 计入
└── 混合类型: → 仅 Number 位匹配的参与求和
方法 5:BizScenarioItemInboxTabNumber
checkHasNoticeTabbarUnreadCount:interactor:reservedInfo:
├── 空数据: → 均为 0
├── Notice 组求和: showType=Number 且未 muted 才计入
├── Shop 210/214 排除: 这两个组的 count 被减去
├── muted 组排除: 不计入
├── 非 Number 类型排除: showType=RedPoint/Default 不计入
├── DM count 叠加: Notice 组 + interactor.countForNumber
├── cellCount 仅来自 interactor: Notice 组不贡献 cellCount
└── data: 上下文切换:
├── 无 context → 返回消息数
└── shouldCountUnreadByCell=true → 返回会话数
5.3 大型工程 Mock 策略
在大型 ObjC/Swift 混合工程中,Mock 是写单测最大的挑战。以下是按场景归纳的策略:
策略一:ObjC Runtime 方法替换(适用于静态方法 / 全局函数依赖)
当被测方法依赖的配置来自静态方法(如 InboxCellRevampConfig.shouldShowRedDotForUnread()),无法通过依赖注入替换时,使用 imp_implementationWithBlock + method_setImplementation 在运行时替换类方法实现。
适用场景:实验开关、配置读取、全局工具方法
关键要点:
-
类方法存在于元类(metaclass)上,需用
object_getClass(cls)获取 -
必须在
tearDown中恢复原始实现,避免测试间污染 -
将恢复逻辑封装为通用 Helper,支持批量 swizzle 和自动还原
// 通用 Helper 模式
class ClassMethodSwizzleHelper {
private var savedIMPs: [(Method, IMP)] = []
func swizzleClassMethod(on cls: AnyClass, selector: Selector, block: Any) {
guard let meta = object_getClass(cls),
let method = class_getInstanceMethod(meta, selector) else { return }
let oldIMP = method_setImplementation(method, imp_implementationWithBlock(block))
savedIMPs.append((method, oldIMP))
}
func restore() {
for (method, imp) in savedIMPs.reversed() {
method_setImplementation(method, imp)
}
savedIMPs.removeAll()
}
}
策略二:协议 Mock 对象(适用于协议依赖)
当被测方法通过协议与其他组件交互时,创建轻量的 Mock 类实现协议。
适用场景:NoticeUnreadCountItemProtocol 等协议接口
关键要点:
-
仅实现测试需要的方法,非必需方法留空实现
-
使用
var属性让测试灵活控制返回值 -
如果系统有白名单机制(如
insertBizItem:的类白名单),可直接操作底层集合绕过
策略三:KVC 设值(适用于 readonly 属性)
ObjC 模型的 readonly 属性仍有底层 ivar,可通过 setValue:forKey: 设值。
适用场景:IMChatModel.type、.mute 等 readonly 属性
注意事项:
-
MTLModel 等特殊基类可能对某些 key 不兼容 KVC(抛出
NSUnknownKeyException) -
如果 KVC 不可用,可尝试
class_getInstanceVariable+object_setIvar直接操作 ivar -
如果属性是计算属性(无 ivar),则需改用 Runtime 方法替换 getter
策略四:公开 API 注入(适用于可通过公共接口设置状态的场景)
优先使用被测对象的公开 API 来构建测试状态,这是最稳定、最不易随重构而失效的方式。
适用场景:UnreadConversationInfoManager.setUpDataSources(with:) + getDataSource(with:) 设置数据源状态
策略五:AB Test Mock(适用于实验开关)
SwiftTestCase 提供了内置的 mockABTest(_:intValue:) 方法,可直接控制 AB 实验的返回值。
适用场景:NoticeReverseAllUnreadUIEnabled() 等读取 Libra 配置的函数
5.4 Swift 测试技术约束
| 约束 | 影响 | 应对 |
|------|------|------|
| Swift 测试无法使用 OCMock | 不能 mock 类、验证调用 | 改用 Runtime swizzle 或手写 Mock 对象 |
| @testable import 可访问 internal 符号 | 能访问内部类型和方法 | 充分利用,减少暴露接口的改动 |
| @objc 方法可被 Runtime 替换 | static/class 方法可 swizzle | 通过元类操作类方法 |
| ObjC 协议在 Swift 中的 optional 方法 | 需通过协议类型调用 | (obj as Protocol).method?() |
六、Step 4:融入迭代,持续发挥价值
6.1 单测在业务迭代中的作用
场景一:实验清理
当一个 AB 实验全量上线后需要清理代码(如 shouldShowRedDotForUnread 全量为 true,需删除 false 分支)。单测能立即告诉你哪些行为变了。如果你删掉了 false 分支的代码,对应的测试用例要么需要删除(符合预期),要么会报错(说明删错了地方)。
场景二:新增过滤条件
产品要求"已归档的会话不显示未读"。假设开发者在 canShowUnreadCountWithModel: 中新增一个 isArchived 判断。此时:
-
已有测试用例会验证新逻辑没有破坏旧行为
-
只需新增 1-2 条覆盖
isArchived的用例
场景三:架构迁移
从 Non-Pagination 架构迁移到 Pagination 架构时,IMUnreadCountDataSource 需要返回 0 让位给 UnreadConversationInfoManager。两侧各自的单测能独立验证各自的正确性,降低联调成本。
场景四:跨模块协作
Notice 模块和 IM 模块由不同团队维护。当 Notice 侧修改了 BizScenarioItemInboxTabNumber 的聚合逻辑,IM 侧的 countForNumber: 和 countForUnreadCell: 返回值不受影响,因为各自有独立的单测保护。
6.2 选择性覆盖策略
不必追求 100% 覆盖率,而是按 "改动频率 × 出错影响" 排优先级:
出错影响大
↑
┌───────────┼───────────┐
│ 值得测 │ 必须测 │
│ (P1) │ (P0) │
├───────────┼───────────┤
│ 可以不测 │ 值得测 │
│ (P3) │ (P1) │
└───────────┼───────────┘
↑
改动频率低 ─────→ 改动频率高
-
P0(必须测):高频改动 + 影响大 → 聚合计算、实验分支
-
P1(值得测):高频改动或影响大 → 过滤逻辑、数据转换
-
P3(可以不测):低频 + 影响小 → 纯 UI 布局、日志上报
6.3 持续维护建议
| 实践 | 说明 |
|------|------|
| 新功能同步加测试 | 新增分支或方法时,同步补充对应用例 |
| 实验清理同步更新 | 全量后删除实验代码时,同步删除或更新对应测试 |
| CI 卡点 | 将单测加入 CI 流程,MR 合入前必须通过全部用例 |
| 测试文件与源码同目录 | 便于开发者在改代码时看到对应测试,降低遗忘概率 |
七、总结
为大型工程补充单测,核心不是"写尽可能多的测试",而是识别关键链路上的聚合与分叉节点,用最少的测试覆盖最高风险的逻辑。
画链路 → 选节点 → 写测试 → 融入迭代
↑ │
└──── 每次迭代反馈 ───────────────┘
本文以"会话列表页未读数"为例展示了这一流程。相同的方法论可直接迁移到其他功能链路(如消息已读回执、会话排序、红点消除等),帮助团队在有限资源下系统性地提升代码质量。