写单元测试时最让人头疼的场景之一,就是被测代码在内部偷偷创建了外部依赖。比如一个订单服务在方法体里直接new了一个数据库连接,或者一个通知模块内部直接调用了邮件SDK,测试一跑就真的去连数据库、真的发邮件。这类问题的根源不在于测试框架不好用,而在于代码把“做什么”和“依赖谁”揉在了一起。依赖注入(Dependency Injection)正是解决这个问题的经典手段:它把依赖的创建权从模块内部移交到外部,让调用方决定传入真实的实现还是替身。这篇文章就来聊聊在JavaScript里如何落地依赖注入,以及它为什么能显著提升代码的可测试性。

为什么硬编码依赖会让测试变得痛苦
先看一段典型的“问题代码”。下面这个订单服务直接在构造函数里创建了数据库客户端和邮件发送器:
class OrderService {
constructor() {
// 依赖在内部创建,外部无法干预
this.db = new DatabaseClient('mysql://localhost:3306/shop');
this.mailer = new SmtpMailer('smtp.ippipp.com');
}
async placeOrder(order) {
await this.db.save('orders', order);
await this.mailer.send(order.email, '订单已创建');
return order.id;
}
}这段代码在功能上没有任何问题,但从测试角度看几乎是灾难。首先,你无法阻止它连接真实的数据库和SMTP服务器,测试要么依赖外部环境,要么干脆跑不通。其次,即使数据库可用,测试速度也会被网络IO拖慢,一个本来几毫秒能结束的用例可能要跑好几秒。最后,一旦邮件服务器返回异常,你很难判断是业务逻辑的bug还是环境问题,测试结果的置信度大打折扣。
本质上,这是控制反转(IoC)缺失的表现。模块自己掌管了依赖的生命周期,等于把实现细节焊死在了代码里。单元测试的核心是“隔离验证”,只验证当前模块的逻辑,把外部依赖全部替换成可控的替身。而要做到这一点,前提是依赖必须能从外部替换进来——这恰恰是依赖注入要解决的问题。
构造函数注入:最直接有效的改造方式
构造函数注入是JavaScript中最常用的依赖注入形式。做法很简单:把内部创建依赖的代码删掉,改成通过构造函数参数接收。改造后的代码如下:
class OrderService {
// 依赖由外部传入,本模块只声明自己需要什么
constructor(db, mailer) {
this.db = db;
this.mailer = mailer;
}
async placeOrder(order) {
await this.db.save('orders', order);
await this.mailer.send(order.email, '订单已创建');
return order.id;
}
}注意这里的变化:OrderService不再关心数据库客户端怎么实例化、邮件服务器地址是什么,它只要求传入的对象“具有save方法和send方法”。这就是所谓的依赖抽象而非依赖实现。在JavaScript这种动态类型语言里,我们不需要正式的Interface声明,鸭子类型天然满足了这一需求。
现在写测试就轻松多了。用Jest可以这样做:
describe('OrderService.placeOrder', () => {
test('下单成功时应保存订单并发送邮件', async () => {
// 创建可控的Mock对象
const mockDb = { save: jest.fn().mockResolvedValue(true) };
const mockMailer = { send: jest.fn().mockResolvedValue(true) };
const service = new OrderService(mockDb, mockMailer);
const order = { id: 1, email: 'user@ipipp.com' };
const result = await service.placeOrder(order);
expect(result).toBe(1);
expect(mockDb.save).toHaveBeenCalledWith('orders', order);
expect(mockMailer.send).toHaveBeenCalledWith('user@ipipp.com', '订单已创建');
});
test('邮件发送失败时不应影响订单结果', async () => {
const mockDb = { save: jest.fn().mockResolvedValue(true) };
const mockMailer = { send: jest.fn().mockRejectedValue(new Error('smtp超时')) };
const service = new OrderService(mockDb, mockMailer);
const result = await service.placeOrder({ id: 2, email: 'user@ipipp.com' });
expect(result).toBe(2);
});
}第二个测试用例尤其值得注意:通过让mockMailer.send返回rejected状态,我们可以在不搭建任何真实SMTP环境的情况下,验证异常分支的行为。这种“制造特定条件”的能力,只有在外部依赖可替换时才具备,而依赖注入正是打开了这扇门。
参数注入与服务容器:应对更复杂的场景
构造函数注入并非唯一选择。对于依赖数量少、生命周期短的函数,直接用参数注入更轻量。比如一个纯函数式的写法:
// 依赖作为参数传入,函数本身保持纯粹
async function placeOrder(deps, order) {
await deps.db.save('orders', order);
await deps.mailer.send(order.email, '订单已创建');
return order.id;
}
// 生产环境调用
await placeOrder({ db: realDb, mailer: realMailer }, order);
// 测试环境调用
await placeOrder({ db: mockDb, mailer: mockMailer }, order);这种把依赖打包成对象放在第一个参数的写法,在TypeScript社区也很流行,配合类型定义可以清晰约束deps的形状。它的优点是函数无状态、易组合,缺点是每次调用都要传依赖,调用点会略显啰嗦,适合中小型工具函数。
当项目规模变大、依赖关系形成网状时,手写注入会变得繁琐,这时候可以引入服务容器(Service Container)来统一管理。容器本质上是一个注册表加一个工厂:模块向容器注册依赖的创建方式,需要时再从容器解析。下面是一个极简实现:
class Container {
constructor() {
this.factories = new Map();
}
// 注册依赖的工厂函数,key可以是字符串或Symbol
register(key, factory) {
this.factories.set(key, factory);
}
// 解析依赖,支持嵌套解析
resolve(key) {
const factory = this.factories.get(key);
if (!factory) throw new Error(`未注册的依赖: ${String(key)}`);
return factory(this);
}
}
// 使用示例
const container = new Container();
container.register('db', () => new DatabaseClient(process.env.DB_URL));
container.register('mailer', () => new SmtpMailer(process.env.SMTP_HOST));
container.register('orderService', (c) =>
new OrderService(c.resolve('db'), c.resolve('mailer'))
);
const orderService = container.resolve('orderService');容器的好处是把依赖的装配逻辑集中到一处,业务代码不再关心依赖从哪来。测试时也有两种策略:一是给容器单独准备一份测试配置,注册的全是Mock工厂;二是在beforeEach里临时覆盖某个key的工厂,测完再恢复。像NestJS这样的框架内置了完整的IoC容器,支持构造函数自动注入、作用域管理和循环依赖检测,如果项目已经用了这类框架,直接利用其容器能力即可,没必要重复造轮子。
实践中的注意事项与常见误区
依赖注入虽好,但用过头也会带来问题。第一个常见误区是“万物皆注入”。如果一个模块依赖的只是一个纯函数工具(比如日期格式化),完全没必要注入,直接import即可,因为它本身没有副作用,测试时不需要替换。注入的价值在于隔离副作用,对无副作用的依赖强行注入只会增加无谓的复杂度。
第二个误区是忽视默认值的设计。为了兼顾开发便利性,可以给构造函数参数设置默认实现,测试时再覆盖:
class OrderService {
constructor(
db = new DatabaseClient(process.env.DB_URL),
mailer = new SmtpMailer(process.env.SMTP_HOST)
) {
this.db = db;
this.mailer = mailer;
}
}
// 生产代码可以无参构造
const service = new OrderService();
// 测试时显式传入Mock
const testService = new OrderService(mockDb, mockMailer);这种写法保留了易用性,又留出了测试口子,是很多库(比如Apollo Server的context设计)采用的方式。不过要注意,默认值里的环境变量读取最好延迟到构造时刻,避免模块加载时就触发配置读取。
最后一点是注入与模块系统的关系。ESM的import是静态的、不可在运行时替换的,所以像jest.mock()这类模块级Mock本质上是编译期替换,虽然可用但会让测试与模块路径强耦合。相比之下,通过参数显式传入依赖的方式不依赖任何测试框架的魔法,测试代码更接近普通代码,可读性和可移植性都更好。这也是为什么很多团队在代码规范里明确要求:外部副作用依赖一律通过注入传入,模块顶部只import纯逻辑和无副作用的内容。
总结一下,依赖注入的核心收益是解耦:它把依赖的创建和使用分离,让业务模块只面向“能力”编程。这种结构上的调整,换来的是测试时随时替换依赖的自由、异常场景的轻松模拟,以及更清晰的模块边界。从下一个新模块开始尝试构造函数注入,你会发现单元测试从“不想写”变成“顺手就能写”。
JavaScript依赖注入单元测试控制反转修改时间:2026-09-12 13:24:42