发布于 2026-01-06 1 阅读
0

JS 测试:模拟最佳实践 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

JS 测试:模拟最佳实践

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

太长不看

你可以用多种方式模拟导入,所有方式都有效,但通常最好用于jest.mock()“静态”模拟,而jest.spyOn()对于需要针对特定​​测试用例更改实现的模拟,则最好使用 `import`。
此外,还有一些全局配置和测试文件结构可以帮助我们做到这一点,并确保我们的测试独立且一致。

问题

在最佳实践中,测试往往最容易被忽视。它们和 CSS 架构或 HTML 可访问性一样,常常被遗忘,直到真正需要它们的时候才会被想起。

JS 测试(尤其是前端测试)还很新,这是不争的事实,我相信随着时间的推移情况会有所改善,但现实情况是,大多数公司仍然没有在其 JS 代码库中添加测试,即使添加了,通常也是一团糟。

在 JavaScript 中,通常可以用多种方法实现相同的结果,但这并不意味着所有方法都一样好。正因如此,我认为最佳实践在我们的生态系统中显得尤为重要。

模拟赛呢?

在测试套件中,模拟测试可能是最难保持井然有序的部分。但你可以采取一些措施来帮助你做到这一点。

首先你需要知道你要编写的是哪种类型的测试。我通常会参考Kent C. Dodds 的这篇文章,他是 JavaScript 测试领域最具影响力的人物之一(如果不是最有影响力的话)。根据测试类型的不同,你要模拟的数据以及模拟的方式也应该有所不同。

一些考虑因素

在本文中,我将给出一些使用 Jest 编写的示例,因为它仍然是目前最流行的 JS 测试运行器,但请注意,如果您使用的是 Vitest(您可能应该使用 Vitest),几乎所有的语法都是相同的,因此本文也适用。

全局配置

Jest 和 Vitest 默认情况下都不会在每次测试后清除、重置或恢复模拟数据。这只是我个人的看法,但我认为默认设置不应该是这样的。

你希望你的测试具有一致性且彼此独立,因此测试的通过或失败不应该依赖于之前的测试。

让我们根据 Jest 的文档看看它的含义clear以及一个模拟示例resetrestore

清除 重置 恢复
清除模拟调用、实例、上下文和结果
移除模拟返回值或实现
恢复原始实现

因此,如果不在全局配置中启用,clearMocks模拟函数将保留之前测试中的所有调用,这很容易导致在断言模拟函数是否被调用时出现误报/假阴性结果,甚至可能出现整个测试套件运行时测试通过,但单独运行该测试时却失败的情况。我们不希望出现这种情况,因此应该在全局配置中clearMocks启用。true

关于resetMocks`and` restoreMocks,情况并非那么简单。如果我们真的希望在每次测试前都保持干净的状态,就应该启用 ` restoreMocks,因为只有它才能恢复原始实现。需要注意的是,如果我们为某个测试模拟了某些东西,通常需要在该测试套件中的所有测试中都模拟它,可能只是模拟实现/返回值有所不同。

即使存在一些例外情况,我仍然建议将其设置restoreMocks为 true true,因为这是确保每个测试独立性的唯一方法,从而实现更可持续的测试套件。但这确实是一个比较强的选择,因此您需要调整组织和设置模拟对象的方式,以避免每个测试中出现代码重复的情况。

幸运的是,我们可以两全其美。Jest 提供了 `get` beforeEachafterEach`get_mock`beforeAll和 ` afterAllget_mock` 方法,配合良好的测试结构,可以确保每个测试都有所需的模拟对象。不过,我们稍后再详细讨论这一点。

jest.fn()对比jest.spyOn()jest.mock()

正如我所说,使用 Jest 或 Vitest 在 JS 中有很多方法可以模拟事物,但即使大多数方法都能达到类似的效果,也不意味着你应该不加区分地使用它们。

同样,根据 Jest文档

  • jest.fn(implementation?)返回一个新的、未使用的模拟函数。可以选择性地接受一个模拟实现。
  • jest.spyOn(object, methodName)创建一个类似于 `func` 的模拟函数jest.fn,但也会跟踪对 `func` 的调用object[methodName]。返回一个 Jest 模拟函数。
  • jest.mock(moduleName, factory, options):在需要模块时,使用自动模拟版本来模拟该模块。factoryoptions是可选的。

何时使用每种方法?

本文不会讨论哪些对象应该或不应该被模拟,那是另一个话题。通常我们会使用这些工具来替换函数的行为,因为我们不希望它影响测试结果。这在测试依赖于其他工具、第三方库或后端接口的代码时非常有用。

jest.fn

这是我们最常用的一个函数。我们通常用它来模拟函数参数(或者在测试组件时模拟函数 props)。

const mockedFn = jest.fn(() => 'mocked return value');
const result = methodToTest(mockedFn)
Enter fullscreen mode Exit fullscreen mode

jest.spyOn

当我们想要模拟一个导入的方法,并且希望根据测试的不同使用不同的模拟实现/返回值时,应该使用这种方法。例如,如果你想测试端点调用响应后的成功和错误流程,那么你的模拟对象应该分别返回 `resolve` 和 `throw`。

const mockedFn = jest
  .spyOn(methodModule, 'methodName')
  .mockReturnValue('mocked return value');
const result = methodToTest()
Enter fullscreen mode Exit fullscreen mode

jest.mock

当你想模拟一个完整的导入模块或其部分内容,并希望它在该文件的所有测试中都具有相同的行为时,可以使用 `mock` jest.mock。建议jest.requireActual在模拟中包含 `mock`,因为这样可以确保模块中未被显式模拟的部分保持其原始实现。

jest.mock('methodModule', () => ({
  ...jest.requireActual('methodModule'),
  methodName: jest.fn(() => 'mocked return value'),
})
Enter fullscreen mode Exit fullscreen mode

但为什么?

当然,你可以分别使用`mock`jest.spyOnjest.mock`mock` 来保持实现的一致性和改变性,但我认为这种方式更合理,你会在本文最后一节中明白这一点。我们的
主要目标是构建一个组织良好的测试结构,为此我们需要做出一些决定。
如果你想改变使用 `mock` 模拟的导入函数的实现/返回值jest.mock,你应该事先声明一个变量,将其赋值jest.fn为一个“默认”实现,然后将其传递给 `mock` jest.mock。之后,你需要在想要更改其实现的特定测试中引用该变量,这使得代码略显冗长,也使顶层模拟的可读性略显复杂。
此外,jest.spyOn`mock` 允许你只模拟导出模块中的特定元素,而无需担心覆盖模块中其他导出的元素。

测试结构

你可能觉得这无关紧要,不久前我也这么想,但它能帮你把前面提到的所有内容整合起来,让你的测试更易读、更可持续、更一致。这就像是锦上添花,让一切都变得有意义。

如果你一直在编写测试,你就会知道我们有describe`and`it代码块,第一个代码块用于对测试进行分组,第二个代码块用于定义一个具体的测试用例。

我们将尝试使用describe代码块来构建测试,并考虑到我们需要测试代码的不同场景。这意味着我们将使用代码块来设置模拟实现,这些模拟实现将在该代码块内的所有测试用例中共享,我们可以使用前面提到的beforeEach方法来实现这一点。

请把代码给我看看。

我举个例子。假设我们有以下函数:

import { irrelevantMethod } from 'module-1';
import { getSomeThings } from 'module-2';

export function getArrayOfThings(amount) {
  if (!amount) {
    return [];
  }
  irrelevantMethod();
  const result = getSomeThings();
  if (!result) return [];
  return result.slice(0, Math.min(amount, 4));
}
Enter fullscreen mode Exit fullscreen mode

我们的函数有一些依赖项,它们分别是:

  • irrelevantMethod这是一个函数必须调用的方法,但它不会以任何方式影响结果(因此得名)。事件跟踪就是一个实际的例子。
  • getSomeThings这个方法确实会影响函数的返回值,所以我们会对其进行模拟,并在一些测试中修改其模拟返回值。我们假设已知此方法只能返回 null 或一个长度固定的有效数组。

如果我们把所有看到的都综合起来,这个方法的测试文件可能看起来像这样:

import * as module2 from 'module-2';
import { getArrayOfThings } from '../utils.js';

const mockedIrrelevantMethod = jest.fn();
jest.mock(() => ({
  ...jest.requireActual('module-1'),
  irrelevantMethod: mockedIrrelevantMethod,
}));

describe('getArrayOfThings', () => {
  it('should return an empty array if amount is 0', () => {
    const result = getArrayOfThings(0);
    expect(result).toEqual([]);
  });

  it('should call irrelevantMethod and getSomeThings if amount is greater than 0', () => {
    const mockedGetSomeThings = jest.spyOn(module2, 'getSomeThings');
    getArrayOfThings(1);
    expect(mockedIrrelevantMethod).toBeCalled();
    expect(mockedGetSomeThings).toBeCalled();
  });

  describe('getSomeThings returns null', () => {
    let mockedGetSomeThings;
    beforeEach(() => {
      mockedGetSomeThings = jest.spyOn(module2, 'getSomeThings').mockReturnValue(null);
    });

    it('should return an empty array', () => {
      const result = getArrayOfThings(1);
      expect(result).toEqual([]);
    });
  });

  describe('getSomeThings returns an array', () => {
    let mockedGetSomeThings;
    beforeEach(() => {
      mockedGetSomeThings = jest.spyOn(module2, 'getSomeThings').mockReturnValue([1, 2, 3, 4, 5, 6]);
    });

    it('should return an array of "amount" elements if "amount" is 4 or less', () => {
      const result = getArrayOfThings(3);
      expect(result).toEqual([1, 2, 3]);
    });

    it('should return an array of 4 elements if "amount" is greater than 4', () => {
      const result = getArrayOfThings(5);
      expect(result).toEqual([1, 2, 3, 4]);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

这是一个非常简单的例子,有时你需要多个describe层级,每个层级都有自己的beforeEach回调函数,这些回调函数会影响该层级内的所有测试。这完全没问题,而且实际上还能让你的测试代码更易读。

结论

这是一种非常个人化的测试和模拟组织方式,但经过多年的 JavaScript 代码测试,这无疑是我目前为止最有效的方法。

文章来源:https://dev.to/alexpladev/js-tests-mocking-best-practices-10kp