测试中的模拟(Mocking)

模拟(Mocking)是创建仿制品、傀儡的一种手段。这通常以 当 'a' 时,做 'b' 的方式进行操作。其思想是限制活动部件的数量,并控制那些“不重要”的东西。严格来说,“模拟(mocks)”和“存根(stubs)”是不同类型的“测试替身(test doubles)”。对于好奇的读者,存根(stub)是一个什么都不做(no-op)但会跟踪其调用的替代品。模拟(mock)则是一个带有虚假实现的存根(即 当 'a' 时,做 'b')。在本文档中,这种差异并不重要,存根也被称为模拟。

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

Node.js 提供了多种方式来模拟代码的各个部分。

本文涉及以下类型的测试

类型描述示例模拟候选对象
单元测试你能隔离的最小代码片段const sum = (a, b) => a + b自己的代码、外部代码、外部系统
组件测试一个单元 + 依赖项const arithmetic = (op = sum, a, b) => ops[op](a, b)外部代码、外部系统
集成测试组件之间的协作-外部代码、外部系统
端到端测试 (e2e)应用程序 + 外部数据存储、交付等一个虚拟用户(例如 Playwright 代理)实际使用连接到真实外部系统的应用程序。无(不模拟)

关于何时模拟、何时不模拟,有不同的学派,其大致思路如下所述。

何时模拟与不模拟

有 3 种主要的模拟候选对象

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

自己的代码

这是你的项目所控制的部分。

import  from './foo.mjs';

export function () {
  const  = ();
}

在这里,foomain 的一个“自有代码”依赖。

为什么

为了对 main 进行真正的单元测试,应该模拟 foo:你测试的是 main 能否正常工作,而不是 main + foo 能否协同工作(那是另一种测试)。

为什么不

模拟 foo 可能得不偿失,特别是当 foo 简单、经过充分测试且很少更新时。

不模拟 foo 可能更好,因为它更真实,并且增加了 foo 的覆盖率(因为 main 的测试也会验证 foo)。然而,这可能会产生干扰:当 foo 出现问题时,其他许多测试也会失败,从而使得追踪问题变得更加繁琐:如果只有最终导致问题的那个测试失败,那么问题就很容易被发现;而 100 个测试失败则需要在大海捞针中寻找真正的问题。

外部代码

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

import  from 'bar';

export function () {
  const  = ();
}

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

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

为什么

验证非项目维护的代码能否正常工作,并不是单元测试的目标(而且那些代码应该有自己的测试)。

为什么不

有时候,模拟并不现实。例如,你几乎永远不会去模拟像 React 或 Angular 这样的大型框架(这样做会弊大于利)。

外部系统

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

理想情况下,不需要模拟这些。除了为每个测试用例创建隔离的副本(通常由于成本、额外的执行时间等原因非常不切实际)外,次优的选择就是模拟。如果不进行模拟,测试会相互干扰。

import {  } from 'db';

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

  if () {
    return .getAll();
  }

  return .getOne();
}

export function (, ) {
  validate(, );

  return .upsert(, );
}

在上面的例子中,第一个和第二个测试用例(it() 语句)可能会相互干扰,因为它们是并发运行的,并且修改了同一个存储(存在竞争条件):save() 的插入操作可能会导致原本有效的 read() 测试因断言找到的项目数量而失败(而 read() 也可能对 save() 造成同样的问题)。

模拟什么

模块 + 单元

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

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

一个鲜为人知的事实是,有一种内置的方法来模拟 fetchundicifetch 的 Node.js 实现。它与 node 一起发布,但目前并未由 node 本身直接暴露,因此必须进行安装(例如 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(), {
      ,
      ,
    });
  });
});

时间

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

请注意此处时区的使用(时间戳中的 Z)。如果不包含一个一致的时区,很可能会导致意想不到的结果。

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

这在与静态固定数据(已签入代码仓库)进行比较时特别有用,例如在快照测试中。