如何使用Sinon对单例对象进行Stub/Spy以编写单元测试
如何用Sinon Spy/Stub单例对象进行单元测试
我完全懂你的痛点——单例因为其“唯一实例”的特性,确实比普通模块方法的Stub/Spy要费点心思,但用Sinon绝对可以搞定,不用只能靠集成测试!下面分两种最常见的单例导出场景来讲解:
场景1:单例实例直接作为模块导出
如果你的单例是直接导出实例(比如下面这种写法):
// singleton.js class DatabaseClient { query(sql) { /* 真实数据库操作 */ } } // 直接导出实例,形成单例 module.exports = new DatabaseClient();
这种情况最简单,你只需要require这个单例实例,然后直接Stub它的方法就行:
const sinon = require('sinon'); const dbClient = require('./singleton'); // 拿到单例实例 const service = require('./your-service-that-uses-db'); // 依赖单例的业务模块 describe('Service', () => { afterEach(() => { // 每个测试后恢复原方法,避免污染其他用例 sinon.restore(); }); it('should call dbClient.query with correct SQL', () => { // 1. Stub单例的query方法 const queryStub = sinon.stub(dbClient, 'query').resolves({ rows: [] }); // 2. 执行要测试的业务方法 service.fetchUsers(); // 3. 验证方法被调用,且参数正确 sinon.assert.calledOnce(queryStub); sinon.assert.calledWith(queryStub, 'SELECT * FROM users'); }); });
原理是Node.js的模块缓存机制:你require的单例实例和业务模块里require的是同一个,所以Stub会直接生效。
场景2:通过工厂函数获取单例
如果你的单例是通过getInstance这类工厂函数暴露的(比如下面的写法):
// singleton.js class CacheService { get(key) { /* 真实缓存操作 */ } } let instance; module.exports = { getInstance: () => { if (!instance) { instance = new CacheService(); } return instance; } };
这里有两种处理方式:
方式A:获取真实实例后Stub方法
先调用getInstance拿到实例,再Stub实例上的方法:
const sinon = require('sinon'); const cacheModule = require('./singleton'); const cacheService = cacheModule.getInstance(); const userRepo = require('./user-repo'); describe('UserRepo', () => { afterEach(() => sinon.restore()); it('should fetch user from cache', () => { const getStub = sinon.stub(cacheService, 'get').returns({ id: 1, name: 'Alice' }); userRepo.getUserById(1); sinon.assert.calledOnce(getStub); sinon.assert.calledWith(getStub, 'user:1'); }); });
方式B:Stub工厂函数返回Mock实例
如果你想完全隔离真实的单例实例(比如避免初始化时的副作用),可以直接StubgetInstance让它返回一个你自己创建的Mock对象:
const sinon = require('sinon'); const cacheModule = require('./singleton'); const userRepo = require('./user-repo'); describe('UserRepo', () => { afterEach(() => sinon.restore()); it('should fetch user from cache', () => { // 创建一个带Spy的Mock实例 const mockCache = { get: sinon.spy(() => ({ id: 1, name: 'Alice' })) }; // Stub getInstance返回这个Mock sinon.stub(cacheModule, 'getInstance').returns(mockCache); userRepo.getUserById(1); // 验证Mock上的方法被调用 sinon.assert.calledOnce(mockCache.get); sinon.assert.calledWith(mockCache.get, 'user:1'); }); });
进阶:用proxyquire绕过模块缓存
如果你的单例初始化有副作用,或者你需要在每个测试用例里重新初始化单例,可以用proxyquire来替换依赖的单例模块:
const sinon = require('sinon'); const proxyquire = require('proxyquire'); describe('UserRepo', () => { it('should fetch user from cache', () => { const mockCache = { get: sinon.spy(() => ({ id: 1, name: 'Alice' })) }; // 用proxyquire加载业务模块,替换掉它依赖的singleton模块 const userRepo = proxyquire('./user-repo', { './singleton': mockCache }); userRepo.getUserById(1); sinon.assert.calledOnce(mockCache.get); }); });
关键注意事项
- 测试隔离:每个测试用例后一定要恢复Stub(用
sinon.restore()或者手动调用stub.restore()),避免Stub污染其他测试。 - 模块缓存:Node.js会缓存模块导出,所以如果你的测试需要重新初始化单例,一定要用
proxyquire或者清除模块缓存(不推荐后者)。
内容的提问来源于stack exchange,提问作者Brandon Parker
相关产品推荐
相关产品推荐

