Jest Spy not being called

Viewed 321

I am trying to run a test using the winston logger package. I want to spy on the createlogger function and assert that it is being called with the correct argument.

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()],
  });

However when i run the test i get the following output

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

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

Number of calls: 0

Im not quite sure what going on. Im using the following versions of packages:

  • "jest": "28.1.0"
  • "ts-jest": "28.0.2"
2 Answers

Assuming you are using ES modules, there are many ways to solve this issue. Honestly, I do not know which one is better (all of them have upsides and downsides), there might even be a well-known solution that I have not found yet, but I doubt it. The reason is that, from what I have read, Jest support for ES modules is still incomplete, as the documentation points out:

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.

So, all the followings are just workarounds, not real solutions.


#1 - Always import the default object

You can import winston in 2 ways:

  1. import * as winston from 'winston': this notation returns a Module object, containing the exported properties. Among them you can find a default property, pointing to module.exports of the CommonJS module.
  2. import winston from 'winston': this is a syntactic sugar for import { default as winston } from 'winston'. Basically, instead of importing the entire module, you just get the default property.

You can read more about it here.

createLogger can be accessed in 2 ways if you use the first import notation:

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

I am not sure mocking a Module object is possible, but in your case it is enough to mock default.createLogger. This is quite easy:

Logger.ts

import winston from 'winston'

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

(Logger.test.ts is the original one.)

Why does this work? Because both Logger.test.ts and Logger.ts assign to winston (a reference to) the default object. jest.spyOn(winston, 'createLogger') creates a spy on the method default.createLogger, because we have imported only the default object. Therefore, the mocked implementation gets shared with Logger.ts as well.

The downside is that an import statement like import { createLogger } from 'winston' cannot work because you are accessing Module.createLogger instead of Module.default.createLogger.


#2 - First mock, then import

With ES modules, import statements are hoisted: even if the first line of your Logger.test.ts was jest.mock('winston', ...), the Logger module would be loaded before that line (because of import { buildLogger } from './Logger';). This means that, in the current state, Logger.ts references the actual implementation of createLogger:

  1. Jest loads Logger.test.ts
  2. Node module loader loads all the modules imported with import ... from ...
  3. Logger.ts is executed, preceded by with import { createLogger } from 'winston'.
  4. Node continues to execute Logger.test.ts, Jest creates a spy on createLogger, but Logger.ts already references the actual implementation of that method.

To avoid the hoisting, a possibility is to use dynamic imports:

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 is the original one.)

Now the module is mocked before importing the logger dependency, which will see the mocked version of winston. Just a few notes here:

  • __esModule: true is probably not necessary in your case (or maybe I was not able to correctly mock the ES module without dynamic imports), but in case you have to mock an ES module that you will use in the current test file, then you have to use it. (See here)
  • I had to configure Jest with transform: {}, see here
  • The upside is that the implementation code stays unchanged, but the test code becomes more complex to handle and maintain. Besides, there could be some situations where this does not work at all.

#3, #4...

There is at least another solution out there, but, just looking at the method name, I would not use it: I am talking about unstable_mockModule. I have not found official documentation for it, but it is probably not ready for production code.

Manual mocks could be another way to solve this, but I have not tried it.


Honestly, I am not fully satisfied with any of these solutions. In this case, I would probably use the first one, at the expense of the implementation code, but I really hope someone finds something better.

Try mocking:

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

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