为大型 iOS 工程补充单元测试方法论 ——以会话列表页未读数为例

4 阅读10分钟

一、背景与目标

在拥有数百个模块、数万乃至数十万个源文件的大型 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.mdworkflow.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 合入前必须通过全部用例 |

| 测试文件与源码同目录 | 便于开发者在改代码时看到对应测试,降低遗忘概率 |


七、总结

为大型工程补充单测,核心不是"写尽可能多的测试",而是识别关键链路上的聚合与分叉节点,用最少的测试覆盖最高风险的逻辑

画链路  →  选节点  →  写测试  →  融入迭代
  ↑                               │
  └──── 每次迭代反馈 ───────────────┘

本文以"会话列表页未读数"为例展示了这一流程。相同的方法论可直接迁移到其他功能链路(如消息已读回执、会话排序、红点消除等),帮助团队在有限资源下系统性地提升代码质量。