跳到内容

测试中的模拟

🌐 Mocking in tests

模拟是一种创建模型或木偶的手段。这通常以“当发生'a'时,执行'b'”的木偶操作方式进行。其理念是限制可移动部分的数量,并控制那些“不重要”的事物。“mocks”(模拟对象)和“stubs”(桩对象)在技术上是不同类型的“测试替身”。对于好奇的人来说,桩对象是一种替代物,它本身不执行任何操作(空操作),但会记录其调用。模拟对象是带有虚假实现的桩对象(“当发生'a'时,执行'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:

typedescriptionexamplemock candidates
unitthe smallest bit of code you can isolateconst sum = (a, b) => a + bown code, external code, external system
componenta unit + dependenciesconst arithmetic = (op = sum, a, b) => ops[op](a, b)external code, external system
integrationcomponents fitting together-external code, external system
end-to-end (e2e)app + external data stores, delivery, etcA fake user (ex a Playwright agent) literally using an app connected to real external systems.none (do not mock)

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

🌐 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

这是你的项目控制的。

🌐 This is what your project controls.

import  from './foo.mjs';

export function () {
  const  = ();
}

这里,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 出现问题时,很多其他测试也会失败,因此追踪问题会更繁琐:如果只有导致问题的具体项的测试失败,这很容易发现;而如果 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  from 'bar';

export function () {
  const  = ();
}

这里,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、node 应用的操作系统等)、文件系统、内存存储等。

🌐 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 {  } from 'db';

export function (,  = false) {
  validate(, val);

  if () {
    return .getAll();
  }

  return .getOne();
}

export function (, ) {
  validate(, );

  return .upsert(, );
}

在上述例子中,第一个和第二个情况(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  from 'node:assert/strict';
import { , , ,  } from 'node:test';

('foo', { : true }, () => {
  const  = .();
  let ;

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

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

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

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

    .((), 42);
  });
});

API

🌐 APIs

一个鲜为人知的事实是,有一种内置的方法可以模拟 fetchundici 是 Node.js 对 fetch 的实现。它随 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  from 'node:assert/strict';
import { , ,  } from 'node:test';

import { ,  } from 'undici';

import  from './endpoints.mjs';

('endpoints', { : true }, () => {
  let ;
  (() => {
     = new ();
    ();
  });

  ('should retrieve data', async () => {
    const  = 'foo';
    const  = 200;
    const  = {
      : 'good',
      : 'item',
    };

    
      .get('https://example.com')
      .intercept({
        : ,
        : 'GET',
      })
      .reply(, );

    .(await .get(), {
      ,
      ,
    });
  });

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

    
      .get('https://example.com')
      .intercept({
        : ,
        : 'PUT',
      })
      .reply(, );

    .(await .save(), {
      ,
      ,
    });
  });
});

时间

🌐 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  from 'node:assert/strict';
import { , ,  } from 'node:test';

import  from './ago.mjs';

('whatever', { : true }, () => {
  ('should choose "minutes" when that\'s the closet unit', () => {
    ..({ : new ('2000-01-01T00:02:02Z') });

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

    .(, '2 minutes ago');
  });
});

当与静态固定件(已提交到代码仓库中)进行比较时,这尤其有用,例如在快照测试中。

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