测试中的 Mock

模拟是一种创建仿制品、傀儡的手段。这通常以 when 'a', do 'b' 的方式进行傀儡操作。其思想是限制移动部件的数量并控制那些“无关紧要”的东西。“mocks”和“stubs”在技术上是不同类型的“测试替身”。对于好奇的人来说,stub 是一种不执行任何操作(空操作)但会跟踪其调用的替代品。mock 是一种也具有伪实现 (when 'a', do 'b') 的 stub。在此文档中,差异并不重要,并且 stubs 被称为 mocks。

测试应该是确定性的:可以以任何顺序、任何次数运行,并且始终产生相同的结果。正确的设置和模拟使这成为可能。

Node.js 提供了多种模拟各种代码片段的方法。

本文讨论以下类型的测试

类型描述示例模拟候选者
单元您可以隔离的最小代码位const sum = (a, b) => a + b自己的代码、外部代码、外部系统
组件一个单元 + 依赖项const arithmetic = (op = sum, a, b) => ops[op](a, b)外部代码、外部系统
集成组件之间的配合-外部代码、外部系统
端到端 (e2e)应用程序 + 外部数据存储、交付等一个假用户(例如 Playwright 代理)实际上使用连接到真实外部系统的应用程序。无(不要模拟)

关于何时模拟和何时不模拟,存在不同的思想流派,其大致轮廓如下。

何时模拟,何时不模拟

有 3 个主要的模拟候选者

  • 自己的代码
  • 外部代码
  • 外部系统

自己的代码

这是您的项目控制的内容。

import foo from './foo.mjs';

export function main() {
  const f = foo();
}

在这里,foomain 的“自己的代码”依赖项。

原因

对于 main 的真实单元测试,应该模拟 foo:您正在测试 main 是否工作,而不是测试 main + foo 是否工作(这是一个不同的测试)。

为什么不

模拟 foo 可能会比值得的麻烦更多,尤其是当 foo 简单、经过良好测试且很少更新时。

不模拟 foo 可能会更好,因为它更真实并且增加了 foo 的覆盖率(因为 main 的测试也将验证 foo)。然而,这可能会产生噪音:当 foo 崩溃时,一堆其他测试也会崩溃,因此跟踪问题更加乏味:如果只有最终负责该问题的项目的 1 个测试失败,那很容易发现;而 100 个测试失败会创建一个大海捞针来找到真正的问题。

外部代码

这是您的项目不控制的内容。

import bar from 'bar';

export function main() {
  const f = bar();
}

在这里,bar 是一个外部包,例如 npm 依赖项。

毫无争议地,对于单元测试,这应该始终被模拟。对于组件和集成测试,是否模拟取决于它是什么。

原因

验证您的项目不维护的代码是否工作不是单元测试的目标(并且该代码应该有自己的测试)。

为什么不

有时,模拟是不现实的。例如,您几乎永远不会模拟像 react 或 angular 这样的大型框架(这种疗法会比疾病更糟糕)。

外部系统

这些是数据库、环境(Web 应用程序的 Chromium 或 Firefox、Node 应用程序的操作系统等)、文件系统、内存存储等。

理想情况下,模拟这些是不必要的。除了以某种方式为每个案例创建隔离副本(由于成本、额外的执行时间等原因,通常非常不切实际)之外,下一个最佳选择是模拟。没有模拟,测试会互相破坏

import { db } from 'db';

export function read(key, all = false) {
  validate(key, val);

  if (all) return db.getAll(key);

  return db.getOne(key);
}

export function save(key, val) {
  validate(key, val);

  return db.upsert(key, val);
}

在上面,第一个和第二个案例(it() 语句)可能会互相破坏,因为它们是同时运行的,并且会改变同一个存储(竞争条件):save() 的插入会导致原本有效的 read() 的测试在找到的项目上断言失败(并且 read() 也可以对 save() 做同样的事情)。

模拟什么

模块 + 单元

这利用了 Node.js 测试运行器中的 mock

import assert from 'node:assert/strict';
import { before, describe, it, mock } from 'node:test';

describe('foo', { concurrency: true }, () => {
  let barMock = mock.fn();
  let foo;

  before(async () => {
    const barNamedExports = await import('./bar.mjs')
      // discard the original default export
      .then(({ default: _, ...rest }) => rest);

    // It's usually not necessary to manually call restore() after each
    // nor reset() after all (node does this automatically).
    mock.module('./bar.mjs', {
      defaultExport: barMock,
      // Keep the other exports that you don't want to mock.
      namedExports: barNamedExports,
    });

    // This MUST be a dynamic import because that is the only way to ensure the
    // import starts after the mock has been set up.
    ({ foo } = await import('./foo.mjs'));
  });

  it('should do the thing', () => {
    barMock.mockImplementationOnce(function bar_mock() {
      /* … */
    });

    assert.equal(foo(), 42);
  });
});

APIs

一个鲜为人知的事实是,有一种内置的方法可以模拟 fetchundicifetch 的 Node.js 实现。它随 node 一起提供,但目前未由 node 本身公开,因此必须安装(例如 npm install undici)。

import assert from 'node:assert/strict';
import { beforeEach, describe, it } from 'node:test';
import { MockAgent, setGlobalDispatcher } from 'undici';

import endpoints from './endpoints.mjs';

describe('endpoints', { concurrency: true }, () => {
  let agent;
  beforeEach(() => {
    agent = new MockAgent();
    setGlobalDispatcher(agent);
  });

  it('should retrieve data', async () => {
    const endpoint = 'foo';
    const code = 200;
    const data = {
      key: 'good',
      val: 'item',
    };

    agent
      .get('https://example.com')
      .intercept({
        path: endpoint,
        method: 'GET',
      })
      .reply(code, data);

    assert.deepEqual(await endpoints.get(endpoint), {
      code,
      data,
    });
  });

  it('should save data', async () => {
    const endpoint = 'foo/1';
    const code = 201;
    const data = {
      key: 'good',
      val: 'item',
    };

    agent
      .get('https://example.com')
      .intercept({
        path: endpoint,
        method: 'PUT',
      })
      .reply(code, data);

    assert.deepEqual(await endpoints.save(endpoint), {
      code,
      data,
    });
  });
});

时间

像奇异博士一样,您也可以控制时间。您通常这样做只是为了方便起见,以避免人为地延长测试运行(您真的想等待 3 分钟来触发 setTimeout() 吗?)。您可能还想穿越时空。这利用了 Node.js 测试运行器中的 mock.timers

请注意此处时区的使用(时间戳中的 Z)。忽略包含一致的时区可能会导致意外的结果。

import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';

import ago from './ago.mjs';

describe('whatever', { concurrency: true }, () => {
  it('should choose "minutes" when that\'s the closet unit', () => {
    mock.timers.enable({ now: new Date('2000-01-01T00:02:02Z') });

    const t = ago('1999-12-01T23:59:59Z');

    assert.equal(t, '2 minutes ago');
  });
});

当与静态装置(已签入存储库)进行比较时,这尤其有用,例如在 快照测试中。