大型 iOS 工程单元测试 — 变更驱动测试与跨模块 Mock

一、定位

本文是 为大型 iOS 工程补充单元测试方法论补充篇。前文提供了"画链路 → 选节点 → 写测试 → 融入迭代"的完整框架,覆盖了基础的 Mock 策略和用例设计原则。

本文聚焦以下问题:

  • 当测试目标不是"从零覆盖"而是"验证一次代码变更"时,应如何设计用例?
  • 当被测方法依赖的实验开关位于另一个模块、通过 Service Locator 解析时,如何 Mock?
  • 当同一份数据有两个来源、且实验开关决定是否去重时,如何构造测试场景?
  • 当方法的聚合语义不是"求和"而是"计数"时,如何防止未来开发者误改?

二、变更驱动测试设计(Change-Driven Test Design)

2.1 原则

传统的"从零覆盖"思路是:遍历方法的所有分支,为每个分支写用例。而在实际业务迭代中,更常见的场景是你刚修改了一段逻辑,需要快速验证变更的正确性

变更驱动测试的核心思路:

复制代码
识别本次变更引入的新分支或行为差异
为新分支写正向用例(验证新行为)
为旧分支写回归用例(验证未被破坏)
如果变更引入了新的数据源排除/包含逻辑,为排除和包含各写至少一条用例

2.2 双态特性开关测试

当一次变更由特性开关(Feature Flag)控制时,同一个方法在 flag=true 和 flag=false 下有不同行为。此时必须成对测试

用例类型 目的 示例
flag=true 正向 验证新行为生效 shouldCountUnreadByCell=true 时 notice groups 被跳过,unreadCount 仅含 interactor 贡献
flag=false 回归 验证旧行为未被破坏 shouldCountUnreadByCell=false 时 notice groups 仍参与累加
flag=true 复合 新行为 + 多种过滤条件叠加 flag=true + muted groups + shop groups + redPoint groups → 全部被跳过,仅剩 interactor

关键原则:回归用例的重要性不低于正向用例。开发者常犯的错误是只测了新路径而遗漏了旧路径的回归验证,有可能改坏了旧路径的功能而未及时发现。

2.3 案例

BizScenarioItemInboxTabNumber.checkHasNoticeTabbarUnreadCount: 在引入 shouldCountUnreadByCell 分支后,新增了 4 条用例:flag=true 跳过 notice groups、flag=true 时 cellCount 不受影响、flag=false 包含 notice groups(回归)、flag=true 复合场景。每条用例的 assertion message 都明确标注了 flag 状态和预期计算过程。


三、Service Center Protocol Mock(跨模块协议依赖的 Mock 策略)

3.1 问题场景

在 Service Locator 架构中,模块间的依赖通过协议(Protocol)解耦。被测代码通过 GET_CLASS(IMModuleService) 获取另一个模块提供的 Class,再调用其类方法。这带来了一个 Mock 难题:

  • IMModuleService协议,不是类,无法直接 swizzle
  • 协议的实现类位于另一个模块,测试工程可能没有链接该模块
  • 在测试环境中,Service Center 默认为空,GET_CLASS 返回 nil

3.2 解法:注册 Mock Class 到测试 Service Center

SwiftTestCaseserviceBehavior = .newCenter 会为每个测试创建隔离的 Service Center。利用基类提供的 mockGetStatelessProtocolService(_:andReturn:) 方法,将一个轻量 Mock 类注册到该 Center 中:

swift 复制代码
// 定义只实现所需类方法的 Mock 类
private class MockIMModuleServiceTrue: NSObject {
    @objc class func shouldCountUnreadByCell() -> Bool { return true }
}

// 在测试中注册
if let proto = NSProtocolFromString("IMModuleService") {
    mockGetStatelessProtocolService(proto, andReturn: MockIMModuleServiceTrue.self)
}

工作原理

csharp 复制代码
被测代码: [GET_CLASS(IMModuleService) shouldCountUnreadByCell]
         ↓
GET_CLASS 查询 Service Center → 返回 MockIMModuleServiceTrue.class
         ↓
[MockIMModuleServiceTrue shouldCountUnreadByCell] → YES

3.3 与 Runtime Swizzle 的对比

维度 Runtime Swizzle Service Center Mock
适用场景 目标类已知且已链接 目标是协议,实现类不可见或位于其他模块
隔离性 全局替换,需手动恢复 仅在测试的隔离 Service Center 内生效,自动还原
tearDown 负担 必须手动调用 restore() 无需手动清理
限制 需要已知类名和方法签名 仅适用于通过 GET_CLASS / Service Locator 解析的依赖

3.4 适用准则

当被测方法通过以下宏/方式获取依赖时,优先使用 Service Center Mock:

  • GET_CLASS(Protocol) / GET_PROTOCOL(Protocol)
  • ServiceCenter.defaultCenter.getStatelessProtocolService()
  • 任何通过 Service Locator 模式解析的跨模块协议依赖

四、Fake Environment 模式(Context Protocol Mock)

4.1 问题场景

某些组件通过一个宽接口的 "Context" 协议获取运行时环境(数据字典、配置管理器、事件分发器等)。直接构造真实 Context 需要初始化整个管理器链路,测试成本极高。

4.2 解法:实现仅含测试所需数据的 Fake Context

swift 复制代码
private class MockUnreadCountContext: NSObject, UnreadCountContext {
    var mockEntranceCountModelDict: [String: InboxEntranceUnreadCountModel] = [:]
    var checkNeedUpdateCalled = false
    var lastCheckScene: BizScenarioItemCheckScene = []

    func entranceCountModelDict() -> [String: InboxEntranceUnreadCountModel]? {
        return mockEntranceCountModelDict
    }
    func checkNeedUpdate(_ scene: BizScenarioItemCheckScene) {
        checkNeedUpdateCalled = true
        lastCheckScene = scene
    }
    // 其余方法空实现
}

4.3 设计要点

要点 说明
只实现被测路径依赖的方法 非必需方法留空实现,降低维护成本
var 暴露可控数据 测试通过直接修改 mockEntranceCountModelDict 来控制输入
Spy 能力 添加 checkNeedUpdateCalled / lastCheckScene 等标记,验证被测方法是否正确触发了 Context 上的副作用
弱引用安全 Context 属性通常为 weak,确保 Mock 对象在测试期间被持有(存为实例属性)

4.4 与协议 Mock 对象的区别

前文的"协议 Mock 对象"聚焦于数据提供者 (如 NoticeUnreadCountItemProtocol),每个 Mock 只需返回数值。Fake Environment 聚焦于运行时环境 ,需要同时提供数据字典、触发副作用(如 checkNeedUpdate:)、并可能被多个被测方法共享。


五、聚合语义测试(Aggregation Semantics Testing)

5.1 问题

聚合方法有两种常见语义,外部签名几乎相同,但行为差异大:

语义 含义 示例方法
Sum 将所有符合条件的项的值相加 countForNumber: → 返回 5 + 3 = 8
Count 统计符合条件且值 > 0 的项的个数 countForUnreadCell: → 返回 2(有 2 项非零)

如果未来开发者误将 Count 语义改为 Sum 语义(或反过来),逻辑上仍然"能跑通",但业务含义错误。

5.2 方法:用数据设计锁定语义

构造让 Sum 和 Count 结果必然不同的测试数据,使得任何语义变更都会导致断言失败:

swift 复制代码
// count 为 5 和 3 → Sum = 8, Count = 2
// 如果断言 Count == 2,则改为 Sum 后结果变为 8,测试失败
func test_countForUnreadCell_countsEntrancesNotSumsCount() {
    mockContext.mockEntranceCountModelDict = [
        combinedKey(1): makeEntranceModel(entranceID: 1, count: 5),
        combinedKey(2): makeEntranceModel(entranceID: 2, count: 3),
    ]
    XCTAssertEqual(item.count(forUnreadCell: nil), 2,
                   "Cell count = number of entrances with unread, not sum of counts")
}

关键:选择每项 count > 1 的数据。如果所有 count 都为 1,则 Sum 和 Count 结果相同,无法区分语义。

5.3 推广

此方法适用于所有存在语义歧义的聚合操作:

  • Max vs Sum:确保数据中有多项,且各项值不同
  • Any vs All:确保数据中有 true 和 false 的混合
  • Distinct count vs Total count:确保数据中有重复项

六、数据源去重测试(Deduplication Testing)

6.1 问题

当同一份业务数据通过两个独立渠道到达聚合点时(如 notice_countentrance_count 都包含通知未读数),需要在特定条件下去重,否则会出现重复计算。

6.2 测试策略

构造"两个渠道都有数据"的场景,验证在去重开关开启时只有一路生效:

ini 复制代码
dataSource (notice groups) = { group100: 10, group200: 3 }
interactor (entrance items) = countForNumber: 5

shouldCountUnreadByCell=true  → result = 5  (只用 interactor,跳过 dataSource)
shouldCountUnreadByCell=false → result = 18 (dataSource 13 + interactor 5)

设计要点

  • 两路数据都给非零值,使得去重与不去重的结果有明显差异
  • 对去重路径和非去重路径各写至少一条用例
  • Assertion message 中明确标注"哪一路被跳过"以及"预期计算过程"

七、变更传播测试(Mutation-Aggregation Vertical Slice)

7.1 问题

底层数据的变更(标记已读、静音)需要正确传播到上层的聚合结果中。仅测试"model.count 被置 0"是不够的,因为聚合层可能因为缓存、过滤条件等原因未感知到变更。

7.2 方法:跨层断言

在一个测试用例中同时操作底层和观察上层,形成"垂直切片":

swift 复制代码
func test_updateMuteStatus_muteExcludesFromCount() {
    // 底层:构造 model
    let model = makeEntranceModel(entranceID: 1, count: 5)
    setUpEntranceModels([combinedKey(1): model])

    // 上层:构造 aggregation item,共享同一个 model
    let countItem = InboxEntranceUnreadCountItem()
    let ctx = MockUnreadCountContext()
    ctx.mockEntranceCountModelDict = [combinedKey(1): model]
    countItem.context = ctx

    XCTAssertEqual(countItem.count(forNumber: nil), 5, "Before mute")

    // 执行变更
    service.updateMuteStatus(true, forEntranceID: 1, subEntranceKey: nil)

    // 验证传播:上层聚合结果反映了底层变更
    XCTAssertEqual(countItem.count(forNumber: nil), 0, "After mute, excluded from count")
}

7.3 适用场景

  • 标记已读 → 未读数归零
  • 静音 → 从聚合计算中排除
  • 归档 → 从聚合计算中排除
  • 任何"底层状态变更应影响上层输出"的链路

7.4 与纯单元测试的关系

垂直切片测试严格来说介于单元测试和集成测试之间。在大型工程中,它的性价比很高:不需要启动完整的 Service 链路,但能验证两层之间的契约是否正确。推荐在以下情况使用:

  • 两层通过共享可变对象(同一个 model 实例)交互
  • 上层的聚合逻辑包含过滤条件(muted、archived 等),变更后的状态可能被过滤

八、短路行为测试(Short-Circuit Testing)

8.1 问题

某些遍历方法在找到第一个匹配项后会 stop*stop = YES),不再继续遍历。如果去掉 stop,方法签名和大部分行为不变,但在存在多个同类型 item 时会错误地累加。

8.2 方法:构造多个同类型 item,验证只取第一个

swift 复制代码
func test_countForNumberWithType_stopsAfterFirstMatch() {
    addMockItem(showType: .number, countForNumber: 10, itemType: .entranceCountItem)
    addMockItem(showType: .number, countForNumber: 5, itemType: .entranceCountItem)
    XCTAssertEqual(
        interactor.count(forNumber: nil, withUnreadCountItemType: .entranceCountItem), 10,
        "Should stop after first matching item"
    )
}

如果 stop 被移除,结果会变为 15,测试失败。

8.3 适用场景

  • 按类型过滤的方法(预期每种类型只有一个活跃实例)
  • 优先级查找方法(返回第一个满足条件的结果)
  • 任何使用 enumerateObjectsUsingBlock: + *stop = YES 的 ObjC 代码

九、总结:何时使用哪种模式

场景 推荐模式 本文章节
验证一次代码变更 变更驱动测试 + 双态 Flag 测试
被测方法依赖跨模块协议(通过 Service Locator) Service Center Protocol Mock
被测对象通过宽接口 Context 获取环境 Fake Environment 模式
聚合方法的 Sum/Count 语义容易被误改 聚合语义测试
同一数据有两个来源、需条件性去重 数据源去重测试
底层变更需传播到上层聚合结果 Mutation-Aggregation 垂直切片
遍历方法有 stop/短路行为 短路行为测试

这些模式与前文的基础方法论互补使用。基础方法论解决"测什么"和"怎么 Mock"的问题,本文解决"改了代码后怎么精准验证"和"复杂依赖场景下怎么构造可控环境"的问题。

相关推荐
东方小月17 小时前
agent-skills:把资深工程师的‘工作流’,装进你的AI编程里
前端·架构·代码规范
醉熏的斑马19 小时前
ASP.Net MVC3 】使用Moq让单元测试变得更简单
单元测试·log4j·asp.net
喝拿铁写前端2 天前
AI 时代,ESLint 应该怎么做得更好
前端·人工智能·代码规范
饼干哥哥2 天前
Lovart真的帮大忙了,一键直出品牌视觉套件和个人IP封面Skill
人工智能·代码规范·设计
饼干哥哥3 天前
单篇100万阅读文章,如何用AI 做好内容创作?
人工智能·正则表达式·代码规范
程序员agions3 天前
RuView 新手快速上手与实战指南
代码规范
ckjoker3 天前
23个测试全绿,一个Token没花——LLM应用的单元测试该怎么写?
单元测试·agent
至乐活着3 天前
前端工程化最佳实践:从规范到自动化构建的全面指南
webpack·自动化·代码规范·构建工具·前端工程化
饼干哥哥4 天前
一篇文章卖了20万,开源CC+Obsidian打造的LLM Wiki 内容创作3.0系统
架构·开源·代码规范
川石课堂软件测试4 天前
性能测试|Nginx中间件监控与调优
linux·python·nginx·中间件·单元测试·压力测试·harmonyos