close
  • 简体中文
  • Mocking

    Mocking 用于在测试中替换依赖、控制返回值,并断言函数或模块的调用行为。Rstest 提供了多组 mock API,分别适用于函数、对象方法、ESM 模块、CommonJS 模块和对象树。

    Mock 模块

    如果依赖是通过模块系统加载的,可以根据模块类型和 mock 方式选择不同的 API。

    Mock ESM 模块

    如果依赖是通过 import 加载的,可以使用 rs.mock()rs.doMock()

    使用 rs.mock()

    rs.mock() 会被提升到文件顶部,适用于在被测模块执行前就替换依赖的场景。

    user-service.test.ts
    import { expect, rs, test } from '@rstest/core';
    import { loadUserName } from './user-service';
    import { fetchUser } from './api';
    
    rs.mock('./api', () => ({
      fetchUser: rs.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
    }));
    
    test('returns the fetched user name', async () => {
      await expect(loadUserName('1')).resolves.toBe('Alice');
      expect(fetchUser).toHaveBeenCalledWith('1');
    });

    使用 rs.doMock()

    需要注意的是,rs.doMock() 不会被提升,只会在执行之后生效。它适用于前面的 import 保持真实实现,后面的再切换成 mock 的场景。

    feature.test.ts
    import { expect, rs, test } from '@rstest/core';
    import { readFeatureFlag } from './feature';
    
    test('only mocks later imports', async () => {
      expect(readFeatureFlag()).toBe('real');
    
      rs.doMock('./feature', () => ({
        readFeatureFlag: () => 'mocked',
      }));
    
      const { readFeatureFlag: mockedReadFeatureFlag } = await import('./feature');
      expect(mockedReadFeatureFlag()).toBe('mocked');
    });

    Mock CommonJS 模块

    如果依赖是通过 require() 加载的,可以使用 rs.mockRequire()rs.doMockRequire()

    使用 rs.mockRequire()

    rs.mockRequire() 会被提升到文件顶部,适用于 CommonJS 模块的文件级 mock 设置。

    math.test.cjs
    const { expect, rs, test } = require('@rstest/core');
    const { sum } = require('./math.cjs');
    
    rs.mockRequire('./math.cjs', () => ({
      sum: (a, b) => a + b + 100,
    }));
    
    test('mocks a CommonJS module loaded with require', () => {
      expect(sum(1, 2)).toBe(103);
    });

    使用 rs.doMockRequire()

    需要注意的是,rs.doMockRequire() 不会被提升,只会影响后续的 require() 调用。

    需要注意的是,如果一个包同时提供 ESM 和 CJS 入口,这个区分尤其重要。mock ESM 入口并不会自动影响 CJS 入口,反过来也一样。

    自动 mock 模块

    如果你希望先把模块中的函数导出替换成 mock 函数,再由测试补充少数导出的行为,可以只传模块路径调用 rs.mock()。Rstest 会先检查 __mocks__ 中是否存在匹配的手写 mock;如果不存在,则回退为自动 mock 该模块。你也可以显式传入 { mock: true },直接请求自动 mock,并跳过手写 mock 查找。

    math.test.ts
    import { expect, rs, test } from '@rstest/core';
    import { add } from './math';
    
    rs.mock('./math');
    
    test('overrides one export', () => {
      rs.mocked(add).mockReturnValue(100);
      expect(add(1, 2)).toBe(100);
    });

    Spy 整个模块

    如果你希望保留真实实现,同时提供调用断言能力,可以使用 { spy: true }

    calculator.test.ts
    import { expect, rs, test } from '@rstest/core';
    import { calculate } from './calculator';
    
    rs.mock('./calculator', { spy: true });
    
    test('keeps the real implementation while tracking calls', () => {
      expect(calculate(1, 2)).toBe(3);
      expect(calculate).toHaveBeenCalledWith(1, 2);
    });

    注意,spy 只能追踪通过 export 发起的调用 —— 同一模块内部函数之间的互相调用不会被追踪。

    部分 mock 模块

    如果你要做部分 mock,让一个导出保留真实实现、另一个导出被替换,可以使用 importActual

    date-utils.test.ts
    import { expect, rs, test } from '@rstest/core';
    import * as actualDateUtils from './date-utils' with { rstest: 'importActual' };
    import { formatDate, parseDate } from './date-utils';
    
    rs.mock('./date-utils', () => ({
      ...actualDateUtils,
      formatDate: rs.fn().mockReturnValue('2026-03-19'),
    }));
    
    test('keeps parseDate real', () => {
      expect(formatDate(new Date())).toBe('2026-03-19');
      expect(parseDate('2026-03-19')).toBeInstanceOf(Date);
    });

    需要注意的是,由于 factory 会被提升,factory 内不应该访问同文件中稍后才初始化的值。

    复用 __mocks__ 里的手写 mock

    如果多个测试会复用同一个 fake 实现,可以把它放进 __mocks__ 目录,并在不传 factory 的情况下直接加载。手写 mock 的优先级高于自动 mock 回退。

    src/
      api.ts
      __mocks__/
        api.ts
    tests/
      user-service.test.ts
    user-service.test.ts
    import { rs } from '@rstest/core';
    
    rs.mock('../src/api');

    重置模块状态

    如果你希望后续的 importrequire() 返回原始模块,可以使用下面这些 API:

    需要注意的是,rs.resetModules() 不会取消模块 mock。要取消模块 mock,需要根据模块的加载方式选择对应的 unmock API。

    关于完整 API 和更多示例,可以参考 Mock modules

    Mock 通过动态 import() 加载的模块

    当模块在 bundle 之外被加载时——通过 specifier 不是静态字符串字面量(例如变量)的动态 import(),或通过被 external 化的 node_modules 依赖在其内部 import 了被 mock 的模块——rs.mock() 同样会生效:

    rs.mock('node:os', () => ({ hostname: () => 'MOCKED' }));
    
    // 1. The variable specifier IS the mocked module:
    const id = 'node:os';
    const os = await import(id); // os.hostname() === 'MOCKED'
    
    // 2. The variable specifier resolves to a module that imports `node:os`:
    const launch = await import(launchScriptPath);
    // the loaded module's internal `os.hostname()` returns 'MOCKED'
    
    // 3. An externalized dependency imports the mocked module internally:
    const dep = await import('some-dep'); // dep's internal `os.hostname()` is mocked
    要求与限制

    情况 1(specifier 解析到被 mock 的模块本身)在所有受支持的 Node.js 版本上都生效。情况 2 和 3——需要 mock 一个由 Node 原生加载的模块的内部 import——还需要满足:

    • Node.js 版本:依赖 module.registerHooks,仅在 Node.js >= 22.15.0>= 23.5.0 上可用。在更低版本上,对原生加载模块的 mock 会被静默跳过(不会报错);字面量 import() 与静态 import 的 mock 行为不变。
    • 编译型语言:由 Node 原生加载的 TypeScript/JSX 模块——对 .ts 文件的变量 import(),或一个 TS 依赖——不在 Rstest 的编译范围内,因此其内部 import 不会被改写。请改用静态字符串字面量 specifier,让该模块被编译进 bundle。
    • 工厂实例:以工厂函数形式创建的 mock(rs.mock(id, () => ({ fn: rs.fn() })))为原生加载的引用方提供的是一个独立实例,因此在工厂内创建的 spy 与 bundle 看到的并非同一个对象。建议使用同步的、返回普通对象的工厂;异步工厂无法被同步的原生 load hook 产出,因此原生加载它的引用方会回退到真实模块。
    • 具名导出的标识:原生路径在 import 时按值重新导出 mock 的每个具名导出。原地 mutate 的 spy 不受影响(binding 仍指向同一对象),但在原生加载的模块已经 import 之后再重新赋值某个具名导出 binding,该消费者不会观察到这次更新。
    • mockRequire 仅适用于 CommonJSrs.mockRequire 针对 require() / CJS 入口。若要 mock 一个通过 import() 加载的模块,请使用 rs.mock
    • 原生路径上的 require():在原生加载的 CommonJS 模块中通过 require() 访问的 mocked 模块不会被 mock——native mock 仅作用于 ESM import,因此该 require() 会拿到真实模块。
    • isolate: false:原生加载的模块会被 Node 的 ESM loader 跨文件缓存,因此对它应用的 mock 可能会保留到同一 worker 运行的后续测试文件中。
    • 跨项目的 isolate: false:非字面量 import(variable) 的 mock 解析发布在共享全局上,因此当一个 worker 运行多个项目时,该解析器反映的是最后执行的那个项目的 runtime——较早项目中对 mocked 模块的变量 import() 可能会查到较晚项目的 mock。静态与字面量 import 不受影响。
    • 相对 specifier:相对路径的 mock 以测试文件为基准解析。写在 setup 文件或共享 helper 中的相对 rs.mock('./dep'),或来自其他目录模块的非字面量相对 import('./dep'),可能无法命中原生加载路径。请改用绝对路径或包名 specifier,或将 mock 声明在测试文件中。

    Mock 函数

    如果依赖是以回调或注入实现的形式传入的,可以使用 rs.fn() 创建 mock 函数。

    user.test.ts
    import { expect, rs, test } from '@rstest/core';
    
    test('passes the selected id to the callback', () => {
      const onSelect = rs.fn();
    
      onSelect('user-1');
    
      expect(onSelect).toHaveBeenCalledTimes(1);
      expect(onSelect).toHaveBeenCalledWith('user-1');
    });

    你也可以通过 mock 实例方法覆盖行为,例如为某一次调用返回不同的结果:

    const fetchUser = rs.fn(async (id: string) => ({ id, role: 'guest' }));
    
    fetchUser.mockResolvedValueOnce({ id: '1', role: 'admin' });

    关于完整 API 和更多示例,可以参考 Mock functionsMockInstance

    Spy 现有方法

    如果你希望保留真实对象,同时跟踪调用或临时覆盖行为,可以使用 rs.spyOn()

    logger.test.ts
    import { expect, rs, test } from '@rstest/core';
    
    test('logs a warning when validation fails', () => {
      const warn = rs.spyOn(console, 'warn').mockImplementation(() => undefined);
    
      console.warn('invalid payload');
    
      expect(warn).toHaveBeenCalledWith('invalid payload');
      warn.mockRestore();
    });

    using 语法

    Rstest 支持使用 using 语法在代码块退出时自动恢复 spy:

    logger.test.ts
    import { expect, rs, test } from '@rstest/core';
    
    test('logs a warning when validation fails', () => {
      {
        using warn = rs.spyOn(console, 'warn').mockImplementation(() => undefined);
    
        console.warn('invalid payload');
    
        expect(warn).toHaveBeenCalledWith('invalid payload');
      }
    
      // console.warn 在这里已恢复
    });

    这类写法常用于 consoleDate 这类全局对象,以及测试里已经存在的共享对象。

    深度 mock 对象

    如果依赖已经存在于内存中,并且你希望把嵌套方法转换成 mock,可以使用 rs.mockObject()

    service.test.ts
    import { expect, rs, test } from '@rstest/core';
    
    test('mocks nested methods', async () => {
      const service = rs.mockObject({
        user: {
          fetch: async (id: string) => ({ id, name: 'real' }),
        },
        version: 'v1',
      });
    
      service.user.fetch.mockResolvedValue({ id: '1', name: 'mocked' });
    
      expect(service.version).toBe('v1');
      await expect(service.user.fetch('1')).resolves.toEqual({
        id: '1',
        name: 'mocked',
      });
    });

    如果你希望保留嵌套方法的原始实现,同时记录调用,可以传入 { spy: true }

    关于完整 API 和更多示例,可以参考 Mock functionsMockInstance

    清理 mock 状态

    如果你要处理调用记录残留或 mock 实现残留,可以使用下面这些 API:

    • clearMocks:每个测试前清空调用记录。
    • resetMocks:清空调用记录并重置 mock 实现。
    • restoreMocks:恢复真实对象上被 spy 的描述符。

    如果你想手动调用,对应的 API 是 rs.clearAllMocks()rs.resetAllMocks()rs.restoreAllMocks()

    延伸阅读