引言
在多人协作的大型工程中,编写单元测试时有一个绑定出现的矛盾:生产代码追求封装,尽可能把实现细节藏在 private 后面;测试代码需要观测和控制内部状态,才能验证逻辑是否正确。
当你依赖的某个属性被另一个团队从 internal 改成了 private,你的测试就从"编译通过"变成了"编译失败"。这在日常开发中反复上演。
本文聚焦于一个具体问题:当被测对象的关键状态被封装为私有时,测试该怎么写? 我们按照推荐优先级介绍三种模式,并讨论如何让这类测试在多人协作中保持可维护。
一、问题的本质
以一个典型场景为例:一个管理器(Manager)内部持有若干数据源(DataSource),它的公有方法 totalCount() 聚合所有数据源的计数。你想测试的是聚合逻辑------非子集的数据源应该被加总,子集的应该被排除。
要测试这个逻辑,你需要让不同数据源返回不同的计数值。但数据源存储在管理器的 private 属性中,计数值也是数据源的 private 属性。你"两头都摸不到"。
直觉的反应是"把它改成 internal 就好了"------但在多人协作的项目中,放松封装来迁就测试往往会带来更大的问题。被放松的接口会被其他模块误用,而且你也不一定有权修改别人的代码。
所以我们需要在不修改生产代码的前提下解决这个问题。
二、三种模式
模式一:通过公有行为间接验证
原则:不直接访问私有状态,而是通过被测对象的公有方法观察其行为。
swift
// 不要直接读取私有变量
XCTAssertEqual(object.privateCount, 10)
// 通过公有接口验证行为
object.performAction()
XCTAssertEqual(object.publicResult(), expectedValue)
适用场景:被测对象有足够的公有 API 来覆盖验证需求。
局限 :当你需要设置特定的内部状态来测试某个分支时(例如"当未读数为 10 时,聚合应返回 10"),仅靠公有 API 可能无法将对象置于期望状态。此时需要下一个模式。
模式二:子类覆写
当需要控制被测对象内部依赖的返回值时,可以在测试文件中定义一个子类,覆写返回内部状态的方法:
Swift
private class StubDataSource: RealDataSource {
private var mockValues: [QueryType: Int] = [:]
func setValue(_ value: Int, for type: QueryType) {
mockValues[type] = value
}
override func getValue(for type: QueryType) -> Int {
return mockValues[type] ?? 0
}
}
被测对象调用 getValue(for:) 时,实际执行的是 Stub 的逻辑,返回我们预设的值。不需要修改任何生产代码。
适用条件 :被覆写的方法是非 final 的。
注意:Stub 子类应保持轻量,只覆写必要的方法。如果父类初始化有副作用(注册通知、启动定时器等),需要留意。
模式三:运行时注入
模式二解决了"让依赖返回可控值"的问题,但还有一个问题:如何把 Stub 塞进被测对象?
理想情况下,被测对象应通过构造器或属性注入依赖。但在大型存量项目中,很多类的依赖是内部创建并存储在 private 属性中的,没有公开的注入点。
此时,对于 NSObject 子类,可以借助 Objective-C 运行时直接操作 ivar:
Swift
private func setIvar<T>(_ name: String, on object: AnyObject, value: inout T) {
let cls: AnyClass = type(of: object)
guard let ivar = class_getInstanceVariable(cls, name) else {
XCTFail("Cannot find ivar '(name)' --- property may have been renamed.")
return
}
let actualSize = computeIvarSize(ivar, in: cls)
guard actualSize == MemoryLayout<T>.size else {
XCTFail("Ivar '(name)' size mismatch --- type may have changed.")
return
}
let offset = ivar_getOffset(ivar)
let ptr = Unmanaged.passUnretained(object).toOpaque().advanced(by: offset)
ptr.assumingMemoryBound(to: T.self).pointee = value
}
这段代码包含两层防护,对应两类变更场景:
| 生产代码变更 | 防护机制 | 测试行为 |
|---|---|---|
| 属性被改名 | class_getInstanceVariable 返回 nil |
XCTFail + 安全返回 |
| 属性名不变,类型变了 | MemoryLayout<T>.size 与 ivar 实际大小不匹配 |
XCTFail + 安全返回 |
这确保了不论生产代码如何变化,测试都报错(failure)而非崩溃(crash) 。
限制 :仅适用于 NSObject 子类。纯 Swift 类没有 ObjC 运行时元数据,此方法不可用。
选择优先级
| 优先级 | 模式 | 条件 | 风险 |
|---|---|---|---|
| 1 | 通过公有行为验证 | 公有 API 足以覆盖 | 无 |
| 2 | 子类覆写 | 方法非 final | 低 |
| 3 | 运行时注入 | NSObject 子类,无注入点 | 中(需防护) |
模式三是"最后手段",不是常规工具。如果你发现自己频繁使用它,更值得推动的是让生产代码提供正式的依赖注入接口。
三、让这类测试在协作中存活
解决了技术问题之后,还有一个同样重要的问题:在多人协作的环境中,这些测试能否被团队中的其他人理解和维护?
3.1 封装脆弱操作,暴露清晰意图
运行时注入是"脆弱"的------它依赖属性名字符串、内存布局等编译器无法检查的假设。关键原则是:把所有脆弱操作封装在一个辅助方法中,让每个测试方法只表达业务意图。
Swift
// 辅助方法封装了所有运行时细节
private func injectStubDataSources(_ entries: [(id: String, isSubset: Bool, ds: StubDataSource)]) {
// ... runtime injection logic ...
}
// 测试方法只表达意图
func test_totalCount_excludesSubset() {
let primary = makeStub(count: 10)
let subset = makeStub(count: 5)
injectStubDataSources([
(id: "primary", isSubset: false, ds: primary),
(id: "filter", isSubset: true, ds: subset)
])
XCTAssertEqual(manager.totalCount(), 10)
}
这样做的好处:
-
单点维护 :属性改名或类型变更时,只需修改
injectStubDataSources一处。 -
可读性:团队成员读到测试方法时,看到的是"注入一个非子集数据源(10)和一个子集数据源(5),期望聚合结果为 10",不需要理解运行时细节。
3.2 用命名传递信息
在多人协作中,测试方法名是最重要的"文档"。一个好的命名应该在不打开方法体的情况下就能传达:测什么、在什么条件下、期望什么结果。
Swift
test_[被测方法]_[场景]
test_totalCount_excludesSubsetDataSources
test_totalCount_multipleDataSources_sumsNonSubset
test_totalCount_allSubset_returnsZero
当这些测试出现在 CI 的失败报告中时,任何人------即使从未接触过这个模块------都能从方法名推断出问题所在。
3.3 报错,不要崩溃
这是使用 unsafe 技术时最重要的设计原则。
-
失败(Failure) :CI 报告中标注
test_totalCount_excludesSubset FAILED: Cannot find ivar 'dataContextMap'。开发者 5 秒内定位问题。 -
崩溃(Crash) :CI 报告中只有
EXC_BAD_ACCESS (code=1, address=0x...)。开发者需要调试半小时。
每一步 unsafe 操作前都必须有 guard ... else { XCTFail(...); return } 的防护链。没有例外。
小结
处理私有成员的测试难题,本质上是在封装性 和可测试性之间找到平衡。在不修改生产代码的前提下,三种模式提供了递进的解决方案:
-
优先通过公有行为验证------最安全,零风险。
-
子类覆写控制返回值------利用多态,风险低。
-
运行时注入作为兜底------突破封装,但必须有防护。
技术方案之外,同样重要的是协作层面的设计:将脆弱操作封装在一处、用命名传递意图、确保测试报错而非崩溃。这些原则让测试不仅"能用",而且"能活"------在团队成员轮换、生产代码持续演进的环境中,持续发挥保护作用。