测试中的模拟

¥Mocking in tests

模拟是一种创建传真、傀儡的手段。这通常以 when 'a', do 'b' 方式进行操纵。这个想法是限制移动部件的数量并控制 "无所谓"。"mocks" 和 "stubs" 在技术上是不同类型的 "测试替身"。对于好奇的人来说,存根是一种替代品,它什么都不做(无操作),但跟踪其调用。模拟是一个存根,它也有一个虚假的实现(when 'a', do 'b')。在本文档中,差异并不重要,存根被称为模拟。

¥Mocking is a means of creating a facsimile, a puppet. This is generally done in a when 'a', do 'b' manner of puppeteering. The idea is to limit the number of moving pieces and control things that "don't matter". "mocks" and "stubs" are technically different kinds of "test doubles". For the curious mind, a stub is a replacement that does nothing (a no-op) but track its invocation. A mock is a stub that also has a fake implementation (the when 'a', do 'b'). Within this doc, the difference is unimportant, and stubs are referred to as mocks.

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

¥Tests should be deterministic: runnable in any order, any number of times, and always produce the same result. Proper setup and mocking make this possible.

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

¥Node.js provides many ways to mock various pieces of code.

本文涉及以下类型的测试:

¥This articles deals with the following types of tests:

typedescriptionexample模拟候选对象
unit你可以隔离的最小代码位const sum = (a, b) => a + b自己的代码、外部代码、外部系统
component单元 + 依赖const arithmetic = (op = sum, a, b) => ops[op](a, b)外部代码,外部系统
integration组件组合在一起*外部代码,外部系统
端到端 (e2e)应用 + 外部数据存储、交付等一个假用户(例如剧作家代理)实际上使用连接到真实外部系统的应用。无(不模拟)

关于何时模拟和何时不模拟有不同的思想流派,其大致概述如下。

¥There are different schools of thought about when to mock and when not to mock, the broad strokes of which are outlined below.

何时模拟以及不模拟

¥When and not to mock

有 3 个主要的模拟候选者:

¥There are 3 main mock candidates:

  • 自己的代码

    ¥Own code

  • 外部代码

    ¥External code

  • 外部系统

    ¥External system

自己的代码

¥Own code

这是你的项目控制的。

¥This is what your project controls.

import foo from './foo.mjs';

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

在这里,foomain 的 "自己的代码" 依赖。

¥Here, foo is an "own code" dependency of main.

为什么

¥Why

对于 main 的真正单元测试,应该模拟 foo:你正在测试 main 是否有效,而不是 main + foo 是否有效(那是不同的测试)。

¥For a true unit test of main, foo should be mocked: you're testing that main works, not that main + foo work (that's a different test).

为什么不

¥Why not

模拟 foo 可能比值得的麻烦更多,特别是当 foo 简单、经过充分测试且很少更新时。

¥Mocking foo can be more trouble than worth, especially when foo is simple, well-tested, and rarely updated.

不模拟 foo 可能会更好,因为它更真实,并且增加了 foo 的覆盖范围(因为 main 的测试也将验证 foo)。但是,这可能会产生噪音:当 foo 中断时,一堆其他测试也会中断,因此追踪问题更加繁琐:如果只有最终导致问题的项目的 1 个测试失败,则很容易发现;而 100 个测试失败会造成大海捞针,难以找到真正的问题。

¥Not mocking foo can be better because it's more authentic and increases coverage of foo (because main's tests will also verify foo). This can, however, create noise: when foo breaks, a bunch of other tests will also break, so tracking down the problem is more tedious: if only the 1 test for the item ultimately responsible for the issue is failing, that's very easy to spot; whereas 100 tests failing creates a needle-in-a-haystack to find the real problem.

外部代码

¥External code

这是你的项目无法控制的。

¥This is what your project does not control.

import bar from 'bar';

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

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

¥Here, bar is an external package, e.g. an npm dependency.

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

¥Uncontroversially, for unit tests, this should always be mocked. For component and integration tests, whether to mock depends on what this is.

为什么

¥Why

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

¥Verifying that code that your project does not maintain works is not the goal of a unit test (and that code should have its own tests).

为什么不

¥Why not

有时,模拟是不现实的。例如,你几乎永远不会模拟大型框架,如 react 或 angular(药物比疾病更糟糕)。

¥Sometimes, it's just not realistic to mock. For example, you would almost never mock a large framework such as react or angular (the medicine would be worse than the ailment).

外部系统

¥External system

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

¥These are things like databases, environments (Chromium or Firefox for a web app, an operating system for a node app, etc), file systems, memory store, etc.

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

¥Ideally, mocking these would not be necessary. Aside from somehow creating isolated copies for each case (usually very impractical due to cost, additional execution time, etc), the next best option is to mock. Without mocking, tests sabotage each other:

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() 做同样的事情)。

¥In the above, the first and second cases (the it() statements) can sabotage each other because they are run concurrently and mutate the same store (a race condition): save()'s insertion can cause the otherwise valid read()'s test to fail its assertion on items found (and read()'s can do the same thing to save()'s).

模拟什么

¥What to mock

模块 + 单元

¥Modules + units

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

¥This leverages mock from the Node.js test runner.

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)。

¥A little-known fact is that there is a builtin way to mock fetch. undici is the Node.js implementation of fetch. It's shipped with node, but not currently exposed by node itself, so it must be installed (ex 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('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('example.com')
      .intercept({
        path: endpoint,
        method: 'PUT',
      })
      .reply(code, data);

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

时间

¥Time

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

¥Like Doctor Strange, you too can control time. You would usually do this just for convenience to avoid artificially protracted test runs (do you really want to wait 3 minutes for that setTimeout() to trigger?). You may also want to travel through time. This leverages mock.timers from the Node.js test runner.

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

¥Note the use of time-zone here (Z in the time-stamps). Neglecting to include a consistent time-zone will likely lead to unexpected restults.

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');
  });
});

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

¥This is especially useful when comparing against a static fixture (that is checked into a repository), such as in snapshot testing.