Ainul Ξ Creative Dev

Ainul Ξ Creative Dev

Product-focused software engineer.
twitter
tg_channel

与导入开玩笑:安全还是抱歉?

所以,我在我的工作区查看测试时,偶然发现了这个奇怪的 “require” 模式,让我感到困惑。

function getMocksForTest(): typeof import('./module-to-test') {
  // 我们不能简单地在模块的顶层导入这个,因为必要的 jest mocks 还没有被应用。
  return require('./module-to-test');
}

it('通过简单测试', () => {
  const { functionToTest } = getMocksForTest();
  expect(functionToTest()).toEqual('foo');
});

我以前在使用 Jest 时从未需要切换到 require 语句,所以看到这个让我感到惊讶。通常我会直接导入模块。

import { functionToTest } from './module-to-test';

it('通过简单测试', () => {
  expect(functionToTest()).toEqual('foo');
});

使用 import 语句要简洁得多。那么为什么使用了不同的模式呢?我刚加入时就让它保持原样,但现在我做了一些研究和软件考古,以更好地理解这个模式的来源、何时需要它以及何时可以移除它。

这个模式仅在使用 jest.mock 时有用#

让我感到困惑的第一件事是名称 getMocksForTest。没有涉及到模拟,所以这个名称似乎不太合适。但我后来发现了一些设置更复杂的测试文件。

function getMocksForTest() {
  const mockCreatePage = jest.fn();

  jest.mock('../pages', () => ({
    ...jest.requireActual('../pages'),
    createPage: mockCreatePage,
  }));

  // 我们不能简单地在模块的顶层导入这个,因为必要的 jest mocks 还没有被应用。
  const { functionToTest } =
    require('./module-to-test') as typeof import('./module-to-test');

  return {
    functionToTest,
    mockCreatePage,
  };
}

it('通过复杂测试', () => {
  const { functionToTest, mockCreatePage } = getMocksForTest();
  mockCreatePage.mockReturnValue('dependency');
  expect(functionToTest()).toEqual('foo+dependency');
});

在这里,我们使用 jest.mock() 来模拟文件 ../pages.ts 中的一个模块。我们正在测试的模块 ./module-to-test.ts 依赖于 ../pages.ts 并导入它。

如果我们在运行 jest.mock() 之前导入了要测试的模块,那么我们的模拟将无法及时设置。相反,../pages.ts 的原始版本将被导入。

import { functionToTest } from './module-to-test';

function getMocksForTest() {
  const mockCreatePage = jest.fn();

  // 太晚了!到这个时候 `../pages` 已经被导入了。
  jest.mock('../pages', () => ({
    ...jest.requireActual('../pages'),
    createPage: mockCreatePage,
  }));

  return {
    mockCreatePage,
  };
}

现在我遇到了一个实际上创建模拟的函数,这个名称更有意义了。后来,开发者会复制粘贴测试,只删除他们不需要的部分。这就是我们最终得到名称为 “获取模拟” 的函数却从未进行任何模拟的原因。

但是 如果一个测试文件从未使用 jest.mock(),那么这个模式完全没有必要。可以清理并直接导入模块。

jest.mock () 不总是在 import 语句之后运行吗?#

现在我们知道,当 jest.mock() 在模块导入后运行时需要这个模式。但许多测试文件确实使用 jest.mock(),而我从未见过这个 require 模式被使用。那么 jest.mock() 通常是如何工作的呢?

事实证明,Jest 在运行测试文件之前会对其进行一些特殊的转换。它不仅将 TypeScript 转换为 JavaScript,还会将 jest.mock() 调用提升到文件顶部,在任何 import 语句之前。这是通过 babel-jest 插件 实现的,该插件默认启用。

因此,像这样的测试文件:

// example.test.ts
import { functionToTest } from './module-to-test';

jest.mock('../pages', () => {
  const mockCreatePage: jest.MockedFn<() => string> = jest.fn();
  return {
    ...jest.requireActual('../pages'),
    createPage: mockCreatePage,
  };
});

it('通过复杂测试', () => {
  const { functionToTest, mockCreatePage } = getMocksForTest();
  mockCreatePage.mockReturnValue('dependency');
  expect(functionToTest()).toEqual('foo+dependency');
});

将被转换为如下的 Node.js 兼容文件:

// compliled.test.js
// 提升到文件顶部
jest.mock('../pages', () => {
  // 移除 TypeScript 类型
  const mockCreatePage = jest.fn();
  return {
    ...jest.requireActual('../pages'),
    createPage: mockCreatePage,
  };
});

// 导入语句转换为 CommonJS
const { functionToTest } = require('./module-to-test');

it('通过复杂测试', () => {
  const { functionToTest, mockCreatePage } = getMocksForTest();
  mockCreatePage.mockReturnValue('dependency');
  expect(functionToTest()).toEqual('foo+dependency');
});

jest.mock 是否总是被提升?#

不!有特定条件需要满足才能提升模块模拟。我从挖掘 Jest 的 babel 插件源代码 中发现了这些检查。

  • jest.mock() 的第一个参数应该是一个字面值。
  • 如果提供了第二个参数,它应该是一个内联函数。
  • 该函数不应引用定义在函数外部的任何变量。
  • jest.mock() 必须在顶层调用,而不是在另一个函数内部。
    • 否则,它会被移动到调用它的函数的顶部。

本质上,如果你不能简单地将 jest.mock() 代码剪切并粘贴到文件顶部,它将不会被自动移动。引用之前定义的其他变量将阻止函数被提升。

// 这些都不会被提升
const moduleName = './module';
jest.mock(moduleName);

function factory() {
  return jest.fn();
}
jest.mock('./module', factory);

jest.mock('./module', () => {
  // 在作用域外定义的变量
  return jest.fn().mockReturnValue(moduleName);
});

如果你确实需要引用在模拟函数外部定义的变量,你可以单独导入它们或使用 jest.doMock 来明确避免提升。

import { createPage } from '../pages';

jest.mock('../pages', () => ({
  createPage: jest.fn(),
}));

const mockCreatePage = createPage as jest.Mocked<typeof createPage>;

只要你的所有模块模拟都可以被提升,使用 import 语句导入你的测试代码是安全的。

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。