您现在的位置是:网站首页 > 模拟(Mock)与桩(Stub)在模式测试中的应用文章详情

模拟(Mock)与桩(Stub)在模式测试中的应用

模拟(Mock)与桩(Stub)的基本概念

模拟和桩是测试替身(Test Double)的两种常见形式,用于隔离被测代码与外部依赖。模拟对象用于验证被测代码与依赖之间的交互行为,而桩对象则用于提供预定义的响应数据。在JavaScript测试中,这两种技术经常结合使用,特别是在单元测试场景下。

模拟对象通常会记录调用信息,并在测试结束时验证这些调用是否符合预期。例如,验证某个方法是否被调用、调用次数以及参数是否正确:

// 使用Jest创建模拟函数
const mockFn = jest.fn()
  .mockReturnValueOnce(42)
  .mockReturnValueOnce('default');

test('mock function behavior', () => {
  expect(mockFn()).toBe(42);
  expect(mockFn()).toBe('default');
  expect(mockFn).toHaveBeenCalledTimes(2);
});

桩对象则更简单,它只是为被测代码提供必要的输入数据,不关心这些数据如何被使用:

// 创建一个简单的桩对象
const userStub = {
  id: 1,
  name: 'Test User',
  email: 'test@example.com'
};

function getUserName(user) {
  return user.name;
}

test('should return user name', () => {
  expect(getUserName(userStub)).toBe('Test User');
});

在单元测试中的应用场景

单元测试的核心目标是隔离测试目标,使其不受外部依赖的影响。模拟和桩在这方面发挥着关键作用。当测试一个依赖于数据库操作的函数时,我们不需要实际连接数据库,而是使用桩来模拟数据库响应:

// 被测函数
async function getUserById(userId, dbClient) {
  return dbClient.query('SELECT * FROM users WHERE id = ?', [userId]);
}

// 测试用例
test('should return user by id', async () => {
  // 创建数据库客户端的桩
  const dbStub = {
    query: jest.fn().mockResolvedValue([{ id: 1, name: 'Mock User' }])
  };
  
  const user = await getUserById(1, dbStub);
  expect(user).toEqual([{ id: 1, name: 'Mock User' }]);
  expect(dbStub.query).toHaveBeenCalledWith(
    'SELECT * FROM users WHERE id = ?',
    [1]
  );
});

对于更复杂的交互测试,比如验证某个回调函数是否被正确调用,模拟就变得非常有用:

// 被测函数
function processData(data, callback) {
  if (data.valid) {
    callback(null, 'Processed successfully');
  } else {
    callback(new Error('Invalid data'));
  }
}

// 测试用例
test('should call callback with success', () => {
  const mockCallback = jest.fn();
  processData({ valid: true }, mockCallback);
  
  expect(mockCallback).toHaveBeenCalledWith(
    null,
    'Processed successfully'
  );
});

test('should call callback with error', () => {
  const mockCallback = jest.fn();
  processData({ valid: false }, mockCallback);
  
  expect(mockCallback).toHaveBeenCalledWith(
    expect.any(Error),
    undefined
  );
});

与测试框架的集成实践

现代JavaScript测试框架如Jest、Mocha等都对模拟和桩提供了良好的支持。Jest内置了强大的模拟功能,可以轻松创建函数模拟、模块模拟和定时器模拟:

// 模拟整个模块
jest.mock('../api', () => ({
  fetchUser: jest.fn().mockResolvedValue({
    id: 1,
    name: 'Mocked User'
  })
}));

// 测试使用该模块的函数
const { getUser } = require('../userService');

test('should return mocked user', async () => {
  const user = await getUser(1);
  expect(user).toEqual({
    id: 1,
    name: 'Mocked User'
  });
});

对于更细粒度的控制,可以创建手动模拟(manual mocks)。在Jest中,通过在__mocks__目录下创建同名文件来实现:

├── src
│   ├── __mocks__
│   │   └── api.js
│   └── api.js

__mocks__/api.js中:

export const fetchUser = jest.fn(() => 
  Promise.resolve({ id: 1, name: 'Manual Mock User' })
);

然后在测试文件中:

jest.mock('../api');

import { fetchUser } from '../api';
import { getUser } from '../userService';

test('should use manual mock', async () => {
  const user = await getUser(1);
  expect(fetchUser).toHaveBeenCalledWith(1);
  expect(user).toEqual({
    id: 1,
    name: 'Manual Mock User'
  });
});

异步代码测试中的特殊考虑

测试异步代码时,模拟和桩需要处理Promise和async/await。常见的模式包括模拟异步函数的延迟响应:

// 模拟带有延迟的API调用
const mockApi = {
  fetchData: jest.fn()
    .mockImplementationOnce(() => 
      new Promise(resolve => 
        setTimeout(() => resolve('data'), 100)
      )
    )
};

test('should handle async response', async () => {
  const promise = mockApi.fetchData();
  expect(mockApi.fetchData).toHaveBeenCalled();
  
  const result = await promise;
  expect(result).toBe('data');
});

对于更复杂的异步流程,可以使用mockResolvedValue和mockRejectedValue:

const paymentService = {
  processPayment: jest.fn()
    .mockResolvedValueOnce({ success: true })
    .mockRejectedValueOnce(new Error('Insufficient funds'))
};

test('should handle successful payment', async () => {
  await expect(paymentService.processPayment({ amount: 100 }))
    .resolves.toEqual({ success: true });
});

test('should handle payment failure', async () => {
  await expect(paymentService.processPayment({ amount: 1000 }))
    .rejects.toThrow('Insufficient funds');
});

测试驱动开发(TDD)中的应用

在测试驱动开发中,模拟和桩可以帮助我们在实现实际功能前定义接口行为。例如,开发一个购物车功能时,可以先定义其依赖的库存服务接口:

// 测试文件
describe('ShoppingCart', () => {
  let cart;
  const mockInventory = {
    checkStock: jest.fn(),
    reduceStock: jest.fn()
  };
  
  beforeEach(() => {
    cart = new ShoppingCart(mockInventory);
    mockInventory.checkStock.mockReset();
    mockInventory.reduceStock.mockReset();
  });
  
  test('should add item when in stock', () => {
    mockInventory.checkStock.mockReturnValue(true);
    
    cart.addItem('product1', 1);
    expect(cart.items).toHaveLength(1);
    expect(mockInventory.checkStock)
      .toHaveBeenCalledWith('product1', 1);
  });
  
  test('should not add item when out of stock', () => {
    mockInventory.checkStock.mockReturnValue(false);
    
    expect(() => cart.addItem('product1', 1))
      .toThrow('Out of stock');
  });
});

然后逐步实现ShoppingCart类:

class ShoppingCart {
  constructor(inventory) {
    this.inventory = inventory;
    this.items = [];
  }
  
  addItem(productId, quantity) {
    const inStock = this.inventory.checkStock(productId, quantity);
    if (!inStock) {
      throw new Error('Out of stock');
    }
    this.items.push({ productId, quantity });
  }
}

边界情况和错误处理

良好的测试应该覆盖各种边界情况和错误处理路径。模拟和桩可以帮助我们轻松模拟这些不常见的情况:

// 测试网络请求失败
const mockHttp = {
  get: jest.fn()
    .mockRejectedValueOnce(new Error('Network error'))
    .mockResolvedValueOnce({ data: 'retry success' })
};

async function fetchWithRetry(url, retries = 3) {
  try {
    return await mockHttp.get(url);
  } catch (err) {
    if (retries <= 0) throw err;
    return fetchWithRetry(url, retries - 1);
  }
}

test('should retry on network failure', async () => {
  const result = await fetchWithRetry('api/data');
  expect(result).toEqual({ data: 'retry success' });
  expect(mockHttp.get).toHaveBeenCalledTimes(2);
});

test('should throw after max retries', async () => {
  mockHttp.get.mockRejectedValue(new Error('Network error'));
  await expect(fetchWithRetry('api/data', 1))
    .rejects.toThrow('Network error');
  expect(mockHttp.get).toHaveBeenCalledTimes(2);
});

性能测试中的应用

虽然模拟和桩主要用于单元测试,但它们也可以在某些性能测试场景中发挥作用。例如,模拟慢速的第三方服务来测试应用的超时处理:

function createSlowService(delay) {
  return {
    fetch: jest.fn(() => 
      new Promise(resolve => 
        setTimeout(() => resolve('data'), delay)
      )
    )
  };
}

test('should timeout slow service', async () => {
  const slowService = createSlowService(1000);
  const promise = Promise.race([
    slowService.fetch(),
    new Promise((_, reject) => 
      setTimeout(() => reject(new Error('Timeout')), 500)
    )
  ]);
  
  await expect(promise).rejects.toThrow('Timeout');
});

与真实测试的平衡

虽然模拟和桩非常有用,但过度使用可能导致测试与实现耦合过紧。一个好的经验法则是:

  1. 对IO密集型操作(网络、数据库)使用模拟/桩
  2. 对纯函数或简单对象使用真实实现
  3. 定期进行集成测试以验证模拟行为与真实环境一致
// 不推荐的过度模拟
class OrderProcessor {
  constructor(paymentService, inventoryService) {
    this.paymentService = paymentService;
    this.inventoryService = inventoryService;
  }
  
  process(order) {
    // 复杂逻辑...
  }
}

// 测试中过度模拟所有依赖
test('over-mocked test', () => {
  const mockPayment = { charge: jest.fn() };
  const mockInventory = { reduce: jest.fn() };
  const processor = new OrderProcessor(mockPayment, mockInventory);
  
  processor.process({ items: [] });
  
  expect(mockPayment.charge).toHaveBeenCalled();
  expect(mockInventory.reduce).toHaveBeenCalled();
});

// 更好的方式:测试核心逻辑,只模拟必要的外部依赖
test('better focused test', () => {
  const mockPayment = { charge: jest.fn() };
  // 使用真实的内存库存服务
  const inventory = new MemoryInventory();
  const processor = new OrderProcessor(mockPayment, inventory);
  
  // 测试特定场景
  inventory.add('item1', 10);
  processor.process({ items: [{ id: 'item1', qty: 1 }] });
  
  expect(mockPayment.charge).toHaveBeenCalled();
  expect(inventory.getStock('item1')).toBe(9);
});

我的名片

网名:~川~

岗位:console.log 调试员

坐标:重庆市-九龙坡区

邮箱:cc@qdcc.cn

沙漏人生

站点信息

  • 建站时间:2013/03/16
  • 本站运行
  • 文章数量
  • 总访问量
微信公众号
每次关注
都是向财富自由迈进的一步