一、定位
本文是 为大型 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
SwiftTestCase 的 serviceBehavior = .newCenter 会为每个测试创建隔离的 Service Center。利用基类提供的 mockGetStatelessProtocolService(_:andReturn:) 方法,将一个轻量 Mock 类注册到该 Center 中:
// 定义只实现所需类方法的 Mock 类
private class MockIMModuleServiceTrue: NSObject {
@objc class func shouldCountUnreadByCell() -> Bool { return true }
}
// 在测试中注册
if let proto = NSProtocolFromString("IMModuleService") {
mockGetStatelessProtocolService(proto, andReturn: MockIMModuleServiceTrue.self)
}
工作原理:
被测代码: [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
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 结果必然不同的测试数据,使得任何语义变更都会导致断言失败:
// 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_count 和 entrance_count 都包含通知未读数),需要在特定条件下去重,否则会出现重复计算。
6.2 测试策略
构造"两个渠道都有数据"的场景,验证在去重开关开启时只有一路生效:
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 方法:跨层断言
在一个测试用例中同时操作底层和观察上层,形成"垂直切片":
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,验证只取第一个
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"的问题,本文解决"改了代码后怎么精准验证"和"复杂依赖场景下怎么构造可控环境"的问题。