测试与代码质量

Singleton为何阻碍测试?用依赖注入解决这个问题

你是否也曾因为Singleton写不出测试,苦恼了一整晚?

3 分钟阅读
Singleton为何阻碍测试?用依赖注入解决这个问题 封面图

你是否也曾因为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. 参数太多时,不会显得很乱吗?

确实如此。因此通常会通过构造函数注入一次性接收,或者将相关内容分组。如果参数不断增加,也可能说明这个类承担了太多职责。

用伪DB替代真实DB,这就是关键
用伪DB替代真实DB,这就是关键

如果测试总是被真实资源拖住,就按照今天的思路只改一个地方。

把直接获取对象的代码改成从外部接收。

这个小习惯会让你更愿意编写测试。祝你今天也能轻松编码!

延伸阅读