Jest Spy 未被调用

Jest Spy not being called

我正在尝试 运行 使用 winston 记录器包进行测试。我想监视 createlogger 函数并断言它是用正确的参数调用的。

Logger.test.ts

import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals';
import { LogLevel } from 'api-specifications';
import winston, { format } from 'winston';
import { buildLogger } from './Logger';
import { LoggerConfig } from './Config';

describe('Logger', () => {
  beforeEach(() => {
    jest.spyOn(winston, 'createLogger');
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('should call winston createLogger with format.json when config.json is true', () => {
    const config: LoggerConfig = {
      json: true,
      logLevel: LogLevel.INFO,
    };
    buildLogger(config);

    expect(winston.createLogger).toHaveBeenCalledWith(
      expect.objectContaining({
        level: LogLevel.INFO,
        format: format.json(),
      }),
    );
  });
});

Logger.ts

import { createLogger, format, transports, Logger } from 'winston';
import { LoggerConfig } from './Config';

const logFormatter = format(info => {
  const values = (info[Symbol.for('splat') as any as string] ?? [])
    .filter(f => typeof f === 'object')
    .reduce(
      (acc, curr) => ({
        ...acc,
        ...curr,
      }),
      {},
    );

  const meta = Object.keys(values)
    .map(k => ` - ${k}=${values[k]}`)
    .join('');

  return { ...info, [Symbol.for('message')]: `${info.level}: ${info.message}${meta}` };
});

export const buildLogger = (config: LoggerConfig): Logger => 
  createLogger({
    level: config.logLevel,
    format: config.json ? format.json() : logFormatter(),
    transports: [new transports.Console()],
  });

然而,当我 运行 测试时,我得到以下输出

expect(jest.fn()).toHaveBeenCalledWith(...expected)

Expected: ObjectContaining {"format": {"options": {}}, "level": "info"}

Number of calls: 0

我不太确定发生了什么。 我正在使用以下版本的软件包:

尝试模拟:

jest.mock('winston', () => {
 return {
   createLogger: jest.fn()
 }
});

describe('Logger', () => {
...

假设你使用的是ES模块,有很多方法可以解决这个问题。老实说,我不知道哪个更好(它们都有优点和缺点),甚至可能有一个 well-known 解决方案我还没有找到,但我对此表示怀疑。原因是,据我所读,Jest 对 ES 模块的支持仍然不完整,正如 documentation 指出的那样:

Please note that we currently don't support jest.mock in a clean way in ESM, but that is something we intend to add proper support for in the future. Follow this issue for updates.

因此,以下所有内容只是变通方法,并非真正的解决方案。


#1 - 始终导入 default 对象

您可以通过两种方式导入 winston

  1. import * as winston from 'winston':此符号 returns 一个 Module 对象,包含 exported 属性。其中可以找到一个default属性,指向CommonJS模块的module.exports
  2. import winston from 'winston':这是import { default as winston } from 'winston'的语法糖。基本上,不是导入整个模块,而是得到 default 属性.

您可以阅读更多相关信息 here

如果使用第一个导入符号,

createLogger 可以通过两种方式访问​​:

[Module] object
{
    ...
    createLogger: f() { ... }
    default: {
        ...
        createLogger: f() { ... }
    }
}

我不确定模拟 Module 对象是否可行,但在您的情况下模拟 default.createLogger 就足够了。这很简单:

Logger.ts

import winston from 'winston'

export const buildLogger = async (config) => {
    return winston.createLogger({
        level: "info"
    });
}

(Logger.test.ts是原来的。)

为什么这样做?因为 Logger.test.tsLogger.ts 都分配给 winston (引用) default 目的。 jest.spyOn(winston, 'createLogger') 在方法 default.createLogger 上创建了一个间谍,因为我们只导入了 default 对象。因此,模拟实现也与 Logger.ts 共享。

缺点是像 import { createLogger } from 'winston' 这样的导入语句无法工作,因为您访问的是 Module.createLogger 而不是 Module.default.createLogger


#2 - 首先模拟,然后导入

对于 ES 模块,import 语句被提升:即使您的 Logger.test.ts 的第一行是 jest.mock('winston', ...)Logger 模块将在该行之前加载(因为 import { buildLogger } from './Logger';)。这意味着,在当前状态下,Logger.ts 引用了 createLogger:

的实际实现
  1. 开玩笑加载Logger.test.ts
  2. 节点模块加载器加载所有使用 import ... from ...
  3. 导入的模块
  4. Logger.ts 被执行,前面加上 import { createLogger } from 'winston'.
  5. Node继续执行Logger.test.ts,Jest在createLogger上创建了一个spy,但是Logger.ts 已经引用了该方法的实际实现。

为了避免提升,一种可能是使用动态导入:

Logger.test.ts

import { jest } from '@jest/globals';
jest.mock('winston', () => {
  return {
    // __esModule: true,
    // default: () => "test",
    createLogger: jest.fn()
  }
});
const winston = await import('winston')

const { buildLogger } = await import('./Logger');

describe('Logger', () => {
  it('should call winston createLogger with format.json when config.json is true', () => {
    const config = {
      json: true,
      logLevel: "info",
    };
    buildLogger(config);

    expect(winston.createLogger).toHaveBeenCalledWith(
      expect.objectContaining({
        level: "info"
      }),
    );
  });
});

(Logger.ts是原来的。)

现在模块在导入记录器依赖项之前被模拟,这将看到 winston 的模拟版本。这里只是一些注意事项:

  • __esModule: true 在您的情况下可能不是必需的(或者我可能无法在没有动态导入的情况下正确模拟 ES 模块),但如果您必须模拟将在中使用的 ES 模块current 测试文件,那么你必须使用它。 (参见 here
  • 我必须使用 transform: {} 配置 Jest,请参阅 here
  • 好处是实现代码保持不变,但测试代码的处理和维护变得更加复杂。此外,在某些情况下,这可能根本不起作用。

#3、#4...

至少还有另一种解决方案,但是,只看方法名称,我不会使用它:我说的是 unstable_mockModule。我还没有找到它的官方文档,但它可能还没有准备好用于生产代码。

手动模拟可能是解决此问题的另一种方法,但我还没有尝试过。


老实说,我对这些解决方案中的任何一个都不完全满意。在这种情况下,我可能会使用第一个,以牺牲实现代码为代价,但我真的希望有人能找到更好的东西。