阐述 Mocking, Stubbing, Spying 在 JavaScript 测试中的应用,以及它们如何隔离测试目标和控制依赖。

各位好!今天咱们来聊聊 JavaScript 测试中的“三剑客”:Mocking, Stubbing, Spying。别怕,这三个听起来像特工代号的家伙,其实是咱们编写可靠、可维护代码的秘密武器。今天,咱们就来扒一扒它们的老底,看看它们是如何在测试中发挥作用的。

开场白:为什么我们需要隔离测试?

想象一下,你要测试一个煎饼果子的制作过程。如果这个煎饼果子机依赖于自动供面系统、自动打蛋系统、自动撒葱花系统等等,任何一个系统出了问题,都会影响你的煎饼果子测试。

同样的道理,在软件测试中,我们的代码往往依赖于其他模块、外部服务、数据库等等。如果这些依赖项不稳定、难以预测,或者测试环境无法完全模拟,那么我们的测试结果就会变得不可靠。甚至,一个外部API的挂掉,可能会直接让你的整个测试跑不起来。这就好比隔壁老王家的wifi断了,导致你没法按时收到快递一样让人郁闷。

所以,我们需要一种方法,将我们的测试目标(比如煎饼果子机)与这些依赖项隔离,以便专注于测试目标本身的逻辑是否正确。这就是 Mocking, Stubbing, Spying 这三个小能手要解决的问题。

第一剑:Stubbing (替身术)

Stubbing,顾名思义,就是用一个“替身”来代替真实的依赖项。这个替身通常只返回预先设定的值,或者执行预先设定的行为,而不会真正地去调用真实的依赖项。

你可以把 Stub 当成是电影里的“替身演员”,他只负责完成一些简单的动作,比如跑跑跳跳,而不需要像主角一样精通十八般武艺。

Stubbing 的作用:

  • 控制依赖项的返回值: 确保测试的目标接收到预期的输入,从而验证目标代码对这些输入的处理是否正确。
  • 简化复杂依赖项: 避免在测试中处理复杂的依赖项逻辑,例如数据库连接、网络请求等。
  • 隔离外部系统: 防止测试直接与外部系统交互,避免测试受到外部系统状态的影响。

Stubbing 示例:

假设我们有一个函数 calculateDiscount,它依赖于一个外部服务 UserService 来获取用户的会员等级,然后根据会员等级计算折扣。

// UserService.js
class UserService {
  getUserLevel(userId) {
    // 模拟从数据库或API获取用户等级
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // 模拟网络延迟
        resolve({ level: 'bronze' }); // 假设默认返回 bronze
      }, 50);
    });
  }
}

// DiscountCalculator.js
class DiscountCalculator {
  constructor(userService) {
    this.userService = userService;
  }

  async calculateDiscount(userId, price) {
    const user = await this.userService.getUserLevel(userId);
    let discount = 0;

    switch (user.level) {
      case 'gold':
        discount = 0.2;
        break;
      case 'silver':
        discount = 0.1;
        break;
      case 'bronze':
        discount = 0.05;
        break;
      default:
        discount = 0;
    }

    return price * (1 - discount);
  }
}

module.exports = { UserService, DiscountCalculator };

现在,我们要测试 calculateDiscount 函数,但我们不想依赖真实的 UserService,因为它可能不稳定,或者需要访问数据库。这时,我们就可以使用 Stubbing 来创建一个 UserService 的替身。

// DiscountCalculator.test.js
const { DiscountCalculator, UserService } = require('./DiscountCalculator');

describe('DiscountCalculator', () => {
  it('should calculate the correct discount for a bronze user', async () => {
    // 创建一个 UserService 的 Stub
    const userServiceStub = {
      getUserLevel: jest.fn().mockResolvedValue({ level: 'bronze' }),
    };

    // 创建 DiscountCalculator 实例,并将 Stub 注入
    const calculator = new DiscountCalculator(userServiceStub);

    // 调用 calculateDiscount 函数
    const discountedPrice = await calculator.calculateDiscount('user123', 100);

    // 断言结果
    expect(discountedPrice).toBe(95);
  });

  it('should calculate the correct discount for a gold user', async () => {
    // 创建一个 UserService 的 Stub
    const userServiceStub = {
      getUserLevel: jest.fn().mockResolvedValue({ level: 'gold' }),
    };

    // 创建 DiscountCalculator 实例,并将 Stub 注入
    const calculator = new DiscountCalculator(userServiceStub);

    // 调用 calculateDiscount 函数
    const discountedPrice = await calculator.calculateDiscount('user123', 100);

    // 断言结果
    expect(discountedPrice).toBe(80);
  });
});

在这个例子中,我们使用 jest.fn().mockResolvedValue() 创建了一个 userServiceStub,它是一个模拟函数,当我们调用 getUserLevel 方法时,它总是返回预先设定的值 { level: 'bronze' } 或者 { level: 'gold' }。 这样,我们就成功地将 DiscountCalculator 与真实的 UserService 隔离,专注于测试 DiscountCalculator 的逻辑是否正确。

第二剑:Mocking (模仿秀)

Mocking 比 Stubbing 更进一步。 Mock 不仅可以提供预先设定的返回值,还可以验证函数是否被调用、调用了多少次、以及调用时传递的参数是什么。

你可以把 Mock 当成是“模仿大师”,他不仅可以模仿别人的声音和动作,还可以记录下自己模仿了多少次,以及每次模仿的细节。

Mocking 的作用:

  • 验证交互: 确保测试目标与依赖项之间进行了预期的交互。
  • 模拟复杂行为: 模拟依赖项的各种行为,包括抛出异常、返回不同的值等。
  • 控制副作用: 防止测试产生不必要的副作用,例如修改数据库、发送邮件等。

Mocking 示例:

假设我们有一个函数 sendEmail,它依赖于一个外部服务 EmailService 来发送邮件。

// EmailService.js
class EmailService {
  send(to, subject, body) {
    // 模拟发送邮件
    console.log(`Sending email to ${to} with subject "${subject}" and body "${body}"`);
    return Promise.resolve();
  }
}

// NotificationService.js
class NotificationService {
  constructor(emailService) {
    this.emailService = emailService;
  }

  async sendWelcomeEmail(user) {
    const subject = 'Welcome to our platform!';
    const body = `Dear ${user.name},nnWelcome to our platform! We hope you enjoy your experience.`;

    await this.emailService.send(user.email, subject, body);
  }
}

module.exports = { EmailService, NotificationService };

现在,我们要测试 sendWelcomeEmail 函数,我们需要验证 EmailService.send 方法是否被调用,以及调用时传递的参数是否正确。

// NotificationService.test.js
const { NotificationService, EmailService } = require('./NotificationService');

describe('NotificationService', () => {
  it('should send a welcome email to the user', async () => {
    // 创建一个 EmailService 的 Mock
    const emailServiceMock = {
      send: jest.fn().mockResolvedValue(),
    };

    // 创建 NotificationService 实例,并将 Mock 注入
    const notificationService = new NotificationService(emailServiceMock);

    // 定义一个用户
    const user = {
      name: 'John Doe',
      email: '[email protected]',
    };

    // 调用 sendWelcomeEmail 函数
    await notificationService.sendWelcomeEmail(user);

    // 断言 EmailService.send 方法被调用
    expect(emailServiceMock.send).toHaveBeenCalled();

    // 断言 EmailService.send 方法被调用时传递的参数正确
    expect(emailServiceMock.send).toHaveBeenCalledWith(
      '[email protected]',
      'Welcome to our platform!',
      `Dear John Doe,nnWelcome to our platform! We hope you enjoy your experience.`
    );
  });
});

在这个例子中,我们使用 jest.fn() 创建了一个 emailServiceMock,它是一个模拟函数。 我们可以使用 toHaveBeenCalled()toHaveBeenCalledWith() 方法来验证 emailServiceMock.send 方法是否被调用,以及调用时传递的参数是否正确。

第三剑:Spying (间谍术)

Spying 允许你监视真实对象的行为,包括函数是否被调用、调用了多少次、以及调用时传递的参数是什么。 与 Mocking 不同,Spying 不会替换真实对象,而是仅仅监视它。

你可以把 Spy 当成是“秘密特工”,他会偷偷地监视目标人物的行动,并记录下目标人物的所作所为。

Spying 的作用:

  • 监视真实对象的行为: 了解真实对象在测试中的表现。
  • 验证交互: 确保测试目标与真实对象之间进行了预期的交互。
  • 用于无法 Mock 的情况: 当无法创建 Mock 对象时,可以使用 Spying 来监视真实对象的行为。

Spying 示例:

假设我们有一个函数 logMessage,它依赖于 console.log 来输出日志信息。

// Logger.js
class Logger {
  logMessage(message) {
    console.log(message);
  }
}

module.exports = Logger;

现在,我们要测试 logMessage 函数,我们需要验证 console.log 是否被调用,以及调用时传递的参数是否正确。 由于 console.log 是一个全局函数,我们无法直接 Mock 它。 这时,我们就可以使用 Spying 来监视 console.log 的行为。

// Logger.test.js
const Logger = require('./Logger');

describe('Logger', () => {
  it('should log the message to the console', () => {
    // 创建一个 Logger 实例
    const logger = new Logger();

    // 使用 jest.spyOn 监视 console.log
    const consoleSpy = jest.spyOn(console, 'log');

    // 调用 logMessage 函数
    logger.logMessage('Hello, world!');

    // 断言 console.log 被调用
    expect(consoleSpy).toHaveBeenCalled();

    // 断言 console.log 被调用时传递的参数正确
    expect(consoleSpy).toHaveBeenCalledWith('Hello, world!');

    // 恢复 console.log 的原始行为
    consoleSpy.mockRestore();
  });
});

在这个例子中,我们使用 jest.spyOn(console, 'log') 创建了一个 consoleSpy,它监视 console.log 函数的行为。 我们可以使用 toHaveBeenCalled()toHaveBeenCalledWith() 方法来验证 console.log 函数是否被调用,以及调用时传递的参数是否正确。 最后,我们需要使用 consoleSpy.mockRestore() 恢复 console.log 的原始行为,以避免影响其他测试。

三剑客的对比:表格说话

特性 Stubbing Mocking Spying
目标 替换依赖项,提供预设返回值 替换依赖项,验证交互,模拟复杂行为 监视真实对象的行为,验证交互
是否替换真实对象
主要用途 控制依赖项的返回值,简化复杂依赖项,隔离外部系统 验证交互,模拟复杂行为,控制副作用 监视真实对象的行为,用于无法 Mock 的情况
常用方法 mockReturnValue, mockResolvedValue toHaveBeenCalled, toHaveBeenCalledWith jest.spyOn, toHaveBeenCalled, mockRestore

一些使用 Mocking, Stubbing, Spying 的最佳实践

  • 只 Mock 你拥有的代码: 避免 Mock 第三方库或框架的代码,因为它们的行为可能会改变,导致你的测试失效。
  • Mock 最小化: 只 Mock 必要的依赖项,尽量使用真实对象,以提高测试的真实性。
  • 保持 Mock 的简单性: 避免创建过于复杂的 Mock 对象,以免增加测试的维护成本。
  • 使用合适的 Mocking 框架: 选择一个适合你的项目的 Mocking 框架,例如 Jest, Mocha, Sinon.js 等。
  • 确保测试的可读性: 使用清晰的命名和注释,使你的测试易于理解和维护。

总结:测试的艺术

Mocking, Stubbing, Spying 是 JavaScript 测试中不可或缺的工具。 它们可以帮助我们隔离测试目标,控制依赖项,验证交互,从而编写出更加可靠、可维护的代码。

掌握这三剑客,就像掌握了测试的艺术。 你可以像一个优秀的指挥家一样,控制你的测试环境,确保你的代码在各种情况下都能正常工作。

希望今天的讲座对你有所帮助! 祝大家编写出更加优秀的 JavaScript 代码! 谢谢!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注