Sinon 教程

1. 核心知识点讲解

1.1 Sinon 简介

Sinon 是一个功能强大的 JavaScript 测试库,专注于提供独立的测试替身(test doubles),包括 Spy、Stub 和 Mock。它可以与任何 JavaScript 测试框架集成,帮助你编写更可靠的测试。

1.2 安装和配置

安装 Sinon:

npm install --save-dev sinon

基本使用:

// 引入 Sinon
const sinon = require('sinon');

// 创建 Spy
const spy = sinon.spy();

// 创建 Stub
const stub = sinon.stub();

// 创建 Mock
const mock = sinon.mock(object);

1.3 Spy(间谍)

Spy 用于观察函数的调用情况,包括调用次数、参数和返回值,但不会改变函数的行为。

基本用法:

const sinon = require('sinon');

// 监视现有函数
const obj = {
  method: function() { return 'original'; }
};

const spy = sinon.spy(obj, 'method');

// 调用函数
obj.method();
obj.method('arg1', 'arg2');

// 检查调用情况
console.log(spy.called); // true
console.log(spy.callCount); // 2
console.log(spy.firstCall.args); // []
console.log(spy.secondCall.args); // ['arg1', 'arg2']

// 恢复原始函数
spy.restore();

Spy 断言:

// 检查是否被调用
spy.called.should.be.true;

// 检查调用次数
spy.callCount.should.equal(2);

// 检查是否以特定参数调用
spy.calledWith('arg1', 'arg2').should.be.true;

// 检查是否以特定参数作为第一个参数调用
spy.calledWithMatch('arg1').should.be.true;

// 检查返回值
spy.returnValues.should.deep.equal(['original', 'original']);

1.4 Stub(存根)

Stub 是 Spy 的扩展,不仅可以观察函数的调用情况,还可以替换函数的实现,控制其返回值或抛出的错误。

基本用法:

const sinon = require('sinon');

// 替换现有函数
const obj = {
  method: function() { return 'original'; }
};

const stub = sinon.stub(obj, 'method');

// 设置返回值
stub.returns('stubbed');

// 调用函数
console.log(obj.method()); // 'stubbed'

// 设置条件返回值
stub.withArgs('arg1').returns('value1');
stub.withArgs('arg2').returns('value2');

console.log(obj.method('arg1')); // 'value1'
console.log(obj.method('arg2')); // 'value2'

// 设置抛出错误
stub.withArgs('error').throws(new Error('Stub error'));

// try {
//   obj.method('error');
// } catch (e) {
//   console.log(e.message); // 'Stub error'
// }

// 恢复原始函数
stub.restore();

Stub 用于异步函数:

// 设置 Promise 返回值
stub.resolves('async result');

// 或设置 Promise 拒绝
stub.rejects(new Error('Async error'));

// 使用 async/await
async function test() {
  const result = await obj.method();
  console.log(result); // 'async result'
}

1.5 Mock(模拟)

Mock 是 Stub 的进一步扩展,它不仅可以替换函数的实现,还可以预先定义期望的调用行为,并在测试结束时验证这些期望是否被满足。

基本用法:

const sinon = require('sinon');

// 创建 Mock 对象
const obj = {
  method1: function() {},
  method2: function() {}
};

const mock = sinon.mock(obj);

// 设置期望
mock.expects('method1').once().withArgs('arg1').returns('value1');
mock.expects('method2').twice();

// 调用函数
obj.method1('arg1');
obj.method2();
obj.method2();

// 验证期望
mock.verify();

// 恢复原始函数
mock.restore();

Mock 验证:

// 验证所有期望都被满足
mock.verify();

// 如果期望未被满足,会抛出错误
// 例如:Expected obj.method1 to be called once but was called 0 times

1.6 计时器控制

Sinon 可以控制 JavaScript 的计时器(setTimeout、setInterval 等),使时间相关的测试更加可靠。

基本用法:

const sinon = require('sinon');

// 替换全局计时器
const clock = sinon.useFakeTimers();

// 测试代码
function delayedFunction() {
  let called = false;
  setTimeout(() => {
    called = true;
  }, 1000);
  return () => called;
}

const checkCalled = delayedFunction();
console.log(checkCalled()); // false

// 快进时间
clock.tick(1000);
console.log(checkCalled()); // true

// 恢复原始计时器
clock.restore();

setInterval 测试:

const sinon = require('sinon');
const clock = sinon.useFakeTimers();

let count = 0;
const intervalId = setInterval(() => {
  count++;
}, 100);

console.log(count); // 0
clock.tick(100);
console.log(count); // 1
clock.tick(100);
console.log(count); // 2

clearInterval(intervalId);
clock.restore();

1.7 沙箱(Sandbox)

沙箱可以帮助你管理多个测试替身,避免手动恢复每个替身。

基本用法:

const sinon = require('sinon');

// 创建沙箱
const sandbox = sinon.createSandbox();

// 在沙箱中创建 Spy、Stub 或 Mock
const obj = {
  method1: function() {},
  method2: function() {}
};

const spy = sandbox.spy(obj, 'method1');
const stub = sandbox.stub(obj, 'method2').returns('stubbed');

// 调用函数
obj.method1();
obj.method2();

// 清理所有测试替身
 sandbox.restore();

// 现在所有函数都已恢复原始状态

在测试框架中使用沙箱:

describe('测试套件', function() {
  let sandbox;

  beforeEach(function() {
    sandbox = sinon.createSandbox();
  });

  afterEach(function() {
    sandbox.restore();
  });

  it('测试用例', function() {
    // 在沙箱中创建测试替身
    const spy = sandbox.spy(obj, 'method');
    // 测试代码
  });
});

2. 实用案例分析

2.1 Spy 使用案例

场景: 测试一个函数是否正确调用了其他函数

实现:

// src/userService.js
function notifyUser(user, message) {
  console.log(`通知用户 ${user.name}: ${message}`);
}

function createUser(userData) {
  const user = {
    id: Math.floor(Math.random() * 10000),
    ...userData
  };
  
  notifyUser(user, '用户创建成功');
  return user;
}

module.exports = {
  createUser,
  notifyUser
};

// test/userService.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const { createUser, notifyUser } = require('../src/userService');

describe('用户服务测试', function() {
  it('创建用户应该调用 notifyUser 函数', function() {
    // 监视 notifyUser 函数
    const spy = sinon.spy(notifyUser);
    
    // 保存原始函数
    const originalNotifyUser = notifyUser;
    
    // 替换模块中的函数
    require.cache[require.resolve('../src/userService')].exports.notifyUser = spy;
    
    try {
      // 创建用户
      const user = createUser({ name: '张三', email: 'zhangsan@example.com' });
      
      // 验证 notifyUser 被调用
      expect(spy.called).to.be.true;
      expect(spy.calledOnce).to.be.true;
      
      // 验证调用参数
      expect(spy.firstCall.args[0]).to.deep.include({ name: '张三' });
      expect(spy.firstCall.args[1]).to.equal('用户创建成功');
    } finally {
      // 恢复原始函数
      require.cache[require.resolve('../src/userService')].exports.notifyUser = originalNotifyUser;
    }
  });
});

2.2 Stub 使用案例

场景: 测试一个依赖外部 API 的函数

实现:

// src/apiService.js
const axios = require('axios');

async function fetchUser(userId) {
  try {
    const response = await axios.get(`https://api.example.com/users/${userId}`);
    return response.data;
  } catch (error) {
    throw new Error(`获取用户失败: ${error.message}`);
  }
}

module.exports = { fetchUser };

// test/apiService.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const axios = require('axios');
const { fetchUser } = require('../src/apiService');

describe('API 服务测试', function() {
  let axiosGetStub;

  beforeEach(function() {
    // 替换 axios.get 方法
    axiosGetStub = sinon.stub(axios, 'get');
  });

  afterEach(function() {
    // 恢复原始方法
    axiosGetStub.restore();
  });

  it('成功获取用户信息', async function() {
    // 设置成功响应
    const mockUser = { id: 1, name: '张三', email: 'zhangsan@example.com' };
    axiosGetStub.resolves({ data: mockUser });

    // 调用函数
    const user = await fetchUser(1);

    // 验证结果
    expect(user).to.deep.equal(mockUser);
    expect(axiosGetStub.calledOnce).to.be.true;
    expect(axiosGetStub.firstCall.args[0]).to.equal('https://api.example.com/users/1');
  });

  it('获取用户失败时应该抛出错误', async function() {
    // 设置失败响应
    axiosGetStub.rejects(new Error('网络错误'));

    // 验证错误
    try {
      await fetchUser(1);
      expect.fail('应该抛出错误');
    } catch (error) {
      expect(error.message).to.equal('获取用户失败: 网络错误');
      expect(axiosGetStub.calledOnce).to.be.true;
    }
  });
});

2.3 Mock 使用案例

场景: 测试一个复杂对象的方法调用顺序和参数

实现:

// src/orderService.js
class OrderService {
  constructor(inventoryService, paymentService, notificationService) {
    this.inventoryService = inventoryService;
    this.paymentService = paymentService;
    this.notificationService = notificationService;
  }

  async processOrder(order) {
    // 1. 检查库存
    const isAvailable = await this.inventoryService.checkAvailability(order.items);
    if (!isAvailable) {
      throw new Error('库存不足');
    }

    // 2. 处理支付
    const paymentResult = await this.paymentService.charge(order.total, order.paymentInfo);
    if (!paymentResult.success) {
      throw new Error('支付失败');
    }

    // 3. 更新库存
    await this.inventoryService.reserveItems(order.items);

    // 4. 发送通知
    await this.notificationService.sendConfirmation(order.customer);

    return { success: true, orderId: order.id };
  }
}

module.exports = OrderService;

// test/orderService.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const OrderService = require('../src/orderService');

describe('订单服务测试', function() {
  let inventoryServiceMock;
  let paymentServiceMock;
  let notificationServiceMock;
  let orderService;

  beforeEach(function() {
    // 创建 Mock 对象
    inventoryServiceMock = sinon.mock({
      checkAvailability: async () => {},
      reserveItems: async () => {}
    });

    paymentServiceMock = sinon.mock({
      charge: async () => {}
    });

    notificationServiceMock = sinon.mock({
      sendConfirmation: async () => {}
    });

    // 创建订单服务实例
    orderService = new OrderService(
      inventoryServiceMock.object,
      paymentServiceMock.object,
      notificationServiceMock.object
    );
  });

  afterEach(function() {
    // 恢复所有 Mock
    inventoryServiceMock.restore();
    paymentServiceMock.restore();
    notificationServiceMock.restore();
  });

  it('处理订单应该按正确顺序调用服务', async function() {
    const order = {
      id: 1,
      items: [{ id: 1, quantity: 2 }],
      total: 100,
      paymentInfo: { cardNumber: '1234567890123456' },
      customer: { id: 1, name: '张三' }
    };

    // 设置期望
    inventoryServiceMock.expects('checkAvailability')
      .once()
      .withArgs(order.items)
      .resolves(true);

    paymentServiceMock.expects('charge')
      .once()
      .withArgs(order.total, order.paymentInfo)
      .resolves({ success: true });

    inventoryServiceMock.expects('reserveItems')
      .once()
      .withArgs(order.items)
      .resolves();

    notificationServiceMock.expects('sendConfirmation')
      .once()
      .withArgs(order.customer)
      .resolves();

    // 处理订单
    const result = await orderService.processOrder(order);

    // 验证结果
    expect(result).to.deep.equal({ success: true, orderId: order.id });

    // 验证所有期望都被满足
    inventoryServiceMock.verify();
    paymentServiceMock.verify();
    notificationServiceMock.verify();
  });

  it('库存不足时应该抛出错误', async function() {
    const order = {
      id: 1,
      items: [{ id: 1, quantity: 2 }],
      total: 100,
      paymentInfo: { cardNumber: '1234567890123456' },
      customer: { id: 1, name: '张三' }
    };

    // 设置期望
    inventoryServiceMock.expects('checkAvailability')
      .once()
      .withArgs(order.items)
      .resolves(false);

    // 验证错误
    try {
      await orderService.processOrder(order);
      expect.fail('应该抛出错误');
    } catch (error) {
      expect(error.message).to.equal('库存不足');
      inventoryServiceMock.verify();
      // 验证后续方法没有被调用
      paymentServiceMock.verify(); // 应该没有期望
    }
  });
});

3. 代码示例

3.1 Spy、Stub 和 Mock 对比

Spy 示例:

const sinon = require('sinon');

// 监视 console.log
const spy = sinon.spy(console, 'log');

console.log('Hello, World!');
console.log('Hello, Sinon!');

console.log('调用次数:', spy.callCount); // 2
console.log('第一次调用参数:', spy.firstCall.args); // ['Hello, World!']
console.log('第二次调用参数:', spy.secondCall.args); // ['Hello, Sinon!']

// 恢复原始函数
spy.restore();

Stub 示例:

const sinon = require('sinon');

// 存根 Math.random
const stub = sinon.stub(Math, 'random').returns(0.5);

console.log(Math.random()); // 0.5
console.log(Math.random()); // 0.5

// 恢复原始函数
stub.restore();

console.log(Math.random()); // 随机值

Mock 示例:

const sinon = require('sinon');

// 模拟对象
const userService = {
  getUser: function(id) {},
  updateUser: function(id, data) {}
};

const mock = sinon.mock(userService);

// 设置期望
mock.expects('getUser').once().withArgs(1).returns({ id: 1, name: '张三' });
mock.expects('updateUser').once().withArgs(1, { name: '李四' }).returns({ id: 1, name: '李四' });

// 调用函数
const user = userService.getUser(1);
console.log(user); // { id: 1, name: '张三' }

const updatedUser = userService.updateUser(1, { name: '李四' });
console.log(updatedUser); // { id: 1, name: '李四' }

// 验证期望
mock.verify();

// 恢复原始函数
mock.restore();

3.2 计时器测试示例

const sinon = require('sinon');
const { expect } = require('chai');

describe('计时器测试', function() {
  let clock;

  beforeEach(function() {
    // 替换全局计时器
    clock = sinon.useFakeTimers();
  });

  afterEach(function() {
    // 恢复原始计时器
    clock.restore();
  });

  it('setTimeout 应该在指定时间后执行', function() {
    let called = false;
    
    setTimeout(() => {
      called = true;
    }, 1000);
    
    expect(called).to.be.false;
    
    // 快进 999 毫秒
    clock.tick(999);
    expect(called).to.be.false;
    
    // 快进 1 毫秒,总共 1000 毫秒
    clock.tick(1);
    expect(called).to.be.true;
  });

  it('setInterval 应该重复执行', function() {
    let count = 0;
    
    setInterval(() => {
      count++;
    }, 100);
    
    expect(count).to.equal(0);
    
    // 快进 100 毫秒
    clock.tick(100);
    expect(count).to.equal(1);
    
    // 快进 100 毫秒,总共 200 毫秒
    clock.tick(100);
    expect(count).to.equal(2);
    
    // 快进 500 毫秒,总共 700 毫秒
    clock.tick(500);
    expect(count).to.equal(7);
  });

  it('clearTimeout 应该取消计时器', function() {
    let called = false;
    
    const timeoutId = setTimeout(() => {
      called = true;
    }, 1000);
    
    // 取消计时器
    clearTimeout(timeoutId);
    
    // 快进 1000 毫秒
    clock.tick(1000);
    expect(called).to.be.false;
  });
});

3.3 沙箱使用示例

const sinon = require('sinon');
const { expect } = require('chai');

describe('沙箱测试', function() {
  let sandbox;

  beforeEach(function() {
    // 创建沙箱
    sandbox = sinon.createSandbox();
  });

  afterEach(function() {
    // 清理沙箱中的所有测试替身
    sandbox.restore();
  });

  it('使用沙箱管理多个测试替身', function() {
    // 在沙箱中创建 Spy
    const spy = sandbox.spy(console, 'log');
    
    // 在沙箱中创建 Stub
    const stub = sandbox.stub(Math, 'random').returns(0.5);
    
    // 在沙箱中创建 Mock
    const obj = { method: function() {} };
    const mock = sandbox.mock(obj);
    mock.expects('method').once();
    
    // 测试代码
    console.log('Hello');
    console.log('Random:', Math.random());
    obj.method();
    
    // 验证
    expect(spy.calledTwice).to.be.true;
    expect(Math.random()).to.equal(0.5);
    mock.verify();
  });

  it('沙箱应该在 afterEach 中清理所有测试替身', function() {
    // 在沙箱中创建 Stub
    sandbox.stub(Math, 'random').returns(0.5);
    
    expect(Math.random()).to.equal(0.5);
  });

  it('上一个测试的测试替身应该被清理', function() {
    // 验证 Math.random 已经恢复原始行为
    const randomValue = Math.random();
    expect(randomValue).to.be.a('number');
    expect(randomValue).to.be.lessThan(1);
    expect(randomValue).to.be.greaterThanOrEqual(0);
  });
});

3.4 与测试框架集成

与 Mocha 集成:

const { expect } = require('chai');
const sinon = require('sinon');

describe('与 Mocha 集成测试', function() {
  let stub;

  beforeEach(function() {
    stub = sinon.stub(Math, 'random');
  });

  afterEach(function() {
    stub.restore();
  });

  it('测试 Math.random 存根', function() {
    stub.returns(0.42);
    expect(Math.random()).to.equal(0.42);
  });

  it('测试 Math.random 另一个存根值', function() {
    stub.returns(0.99);
    expect(Math.random()).to.equal(0.99);
  });
});

与 Jest 集成:

const sinon = require('sinon');

describe('与 Jest 集成测试', () => {
  let stub;

  beforeEach(() => {
    stub = sinon.stub(Math, 'random');
  });

  afterEach(() => {
    stub.restore();
  });

  test('测试 Math.random 存根', () => {
    stub.returns(0.42);
    expect(Math.random()).toBe(0.42);
  });

  test('测试 Math.random 另一个存根值', () => {
    stub.returns(0.99);
    expect(Math.random()).toBe(0.99);
  });
});

4. 总结

Sinon 是一个功能强大的 JavaScript 测试库,提供了三种主要的测试替身:

  • Spy:用于观察函数的调用情况,包括调用次数、参数和返回值
  • Stub:用于替换函数的实现,控制其返回值或抛出的错误
  • Mock:用于预先定义期望的调用行为,并在测试结束时验证这些期望

此外,Sinon 还提供了计时器控制功能,可以精确控制时间相关的测试,以及沙箱功能,可以方便地管理多个测试替身。

通过学习本教程,你可以开始在项目中使用 Sinon 编写更加可靠、可预测的测试,特别是在处理外部依赖、时间相关逻辑和复杂的函数调用链时。

5. 进一步学习资源

希望本教程对你学习 Sinon 有所帮助!

« 上一篇 Chai 教程 下一篇 » Supertest 教程