你是否也曾因为Singleton写不出测试,苦恼了一整晚?
明明逻辑没有问题,但一添加测试就不断访问真实数据库、调用真实API。
先说结论。
Singleton会在代码中直接获取对象,因此测试时无法将它替换成伪对象。
依赖注入则从外部传入所需对象,因此可以在测试中自由替换。
今天我们结合实际代码,逐步讲解两者的区别。
Singleton为什么会阻碍测试?
Singleton是整个程序中只存在一个的对象。
确实很方便,因为在任何地方只需一行Database.shared就能取用。
问题就从这里开始。
如果在函数内部直接获取对象,这个函数就会紧密耦合到真实数据库。
测试时无法告诉它:“别用真实DB,改用伪DB。”
// 在函数内部直接获取Singleton
func saveOrder(_ order: Order) {
// 紧密耦合到真实 DB
let db = Database.shared
db.insert(order) // 测试中没有办法阻止它
}
要测试这段代码,真实DB必须处于运行状态。
如果DB关闭,测试也会一起失败。
逻辑并没有错,却因为基础设施让测试亮起红灯。
依赖注入该怎么做?
依赖注入没有想象中那么复杂。
不要在函数内部获取对象,只要在外部创建后传进来即可。
顾名思义,就是把需要的东西“注入”进去。
// DB从外部作为参数接收
func saveOrder(_ order: Order, db: Database) {
db.insert(order) // 具体是哪种 DB由调用方决定
}
区别就一行,对吧?
只是把原本通过Database.shared直接获取的对象,改成通过参数接收。
然而,这个小改动会彻底改变测试。
实际代码中传入真实DB,测试中传入伪DB即可。
通过构造函数接收也是常见做法。
class OrderService {
private let db: Database
// 创建对象时从外部接收并保存
init(db: Database) { self.db = db }
}
接收一次后,类中任何地方都可以通过self.db使用,整洁得多。
在测试中替换为伪对象
现在进入真正有趣的部分。
既然依赖改为从外部传入,测试时只需用伪DB替代真实DB(通常称为Mock)。
@Test func 保存_订单后,会将它_DB传递给_() {
let fake = FakeDatabase() // 不是实际的,而是伪对象
saveOrder(Order(), db: fake)
#expect(fake.savedCount == 1) // 只确认是否调用了保存
}
不需要启动真实DB。
网络和外部服务器也都不需要,在内存中瞬间即可完成。
因此测试更快,更重要的是更加稳定。
无论外部情况如何,都可以只验证逻辑本身。
Singleton vs. 依赖注入:一眼看懂差异
我把两者的区别整理成了表格。
| 分类 | 直接使用Singleton | 依赖注入 |
|---|---|---|
| 获取对象的位置 | 在函数内部直接获取 | 从外部传入 |
| 替换伪对象 | 实际上不可能 | 可以自由替换 |
| 测试速度 | 慢(需要真实资源) | 快(在内存中处理) |
| 测试稳定性 | 受外部情况影响 | 只能验证逻辑 |
| 耦合度 | 高 | 低 |
Singleton本身并不一定不好。
对于配置值这类确实只需要一个实例的情况,它很方便。
问题在于“在任何地方直接获取”的习惯,因为这会束缚测试的手脚。
常见问题(Q&A)
Q. 要使用依赖注入,必须使用Swinject之类的DI库吗?
不需要。像今天的代码一样,通过参数或构造函数传入,本身就已经是依赖注入。库只是负责自动管理。
Q. 参数太多时,不会显得很乱吗?
确实如此。因此通常会通过构造函数注入一次性接收,或者将相关内容分组。如果参数不断增加,也可能说明这个类承担了太多职责。
如果测试总是被真实资源拖住,就按照今天的思路只改一个地方。
把直接获取对象的代码改成从外部接收。
这个小习惯会让你更愿意编写测试。祝你今天也能轻松编码!

