How to mock NestJS built-in Logger in Jest
Asked Answered
C

6

7

I have a controller that uses NestJS built-in Logger via dependency injection in constructor of the controller:

  constructor(private readonly logger: Logger) 

I want to be able to mock it in my Jest tests to see which methods and with what arguments are being called during logging. I tried this syntax:

providers[{
    provide: Logger,
    useValue: {
      log: jest.fn(),
    }
}]

In that case this line:

    expect(Logger).toHaveBeenCalledTimes(1);

Returns: Matcher error: received value must be a mock or spy function

Any help will be highly appreciated!

Chrystel answered 15/7, 2021 at 13:40 Comment(0)
L
16

In your test, you should get the logger back out of the DI context using moduleFixture.get(Logger) (or something very similar) and then check expect(logger.log).toHaveBeenCalledTimes(1). Logger itself is a class, not a spy or mock, so Jest doesn't know what to do with that.

Full solution that worked:

import { Test } from '@nestjs/testing';
let logger: Logger;

beforeEach(async () => {
  const moduleRef = await Test.createTestingModule({
    providers: [  
      {
        provide: Logger,
        useValue: {
          log: jest.fn(),
        },
      },
    ],
  }).compile();
  logger = moduleRef.get<Logger>(Logger);
});

And then later in the test itself:

expect(logger.log).toHaveBeenCalledTimes(1);
expect(logger.log).toHaveBeenCalledWith('Your log message here')
Lilith answered 15/7, 2021 at 16:5 Comment(4)
Indeed, I was trying so many options that got confused in them. What worked is: logger = moduleRef.get<Logger>(Logger); And then: expect(logger.log).toHaveBeenCalledTimes(1); – Chrystel
I still couldn't get it to work for whatever reason, so I ended up creating a new logger class that extends ConsoleLogger and dependency injected it in since that was a standard for testing anyways. It ended up working nicely because I could put Sentry in that extended class abstracted out of other locations which made testing easier down the road πŸ‘. – Fire
It's frustrating that their documentation is lacking around how to use the built-in logger. I end up back here a few months later looking again πŸ˜† I'm not sure why their logger doesn't seem to follow their own dependency injection model which would make this like testing anything else in the NestJS DI ecosystem. – Fire
@Fire most likely it depends on how the logger is used. I use a custom logger I wrote called ogma most of the time. – Lilith
U
4

I use this for private loggers in unit tests:

import { Logger } from '@nestjs/common';

const loggerSpyLog = jest.spyOn(Logger.prototype, 'log');
const loggerSpyWarn = jest.spyOn(Logger.prototype, 'warn');

expect(loggerSpyLog).toHaveBeenCalledTimes(3);
expect(loggerSpyWarn).not.toHaveBeenCalled();
Unbind answered 21/12, 2023 at 10:57 Comment(2)
Thank you for contributing to the Stack Overflow community. This may be a correct answer, but it’d be really useful to provide additional explanation of your code so developers can understand your reasoning. This is especially useful for new developers who aren’t as familiar with the syntax or struggling to understand the concepts. Would you kindly edit your answer to include additional details for the benefit of the community? – Pinebrook
very nice, neat and concise – Lavery
F
3

I'm going to leave this as an answer for how I've been handling this lately. So next time I'm Googling and end up here I'll remember πŸ˜†

❌ jest.mock Without Success

I've tried using jest.mock like I normally would on imports for the Logger class from @nest/common, but it seems to cause its own problems. Even if you try to keep the original implementation like so:

jest.mock('@nestjs/common', () => ({
  ...jest.requireActual('@nestjs/common'),
  Logger: jest.fn(),
}))

I still want to believe there has to be a way to accomplish it like this, but maybe Nest JS's dependency system circumvents Jest's hoisted mocking? 🀷

βœ… Using a Custom Logger with Nest JS's Dependency Injection

This feels like unnecessary lifting, but it follows Nest JS's dependency injection, and allows for extending or overwriting later. If you're already writing tests for Nest JS you're likely familiar with it already.

custom.logger.ts

import { ConsoleLogger } from '@nestjs/common'

export class CustomLogger extends ConsoleLogger {}

some-consumer.spec.ts

This approach uses the jest-mock-extended library, but you could also do something like @Jay McDoniel's answer as well.

import { SomeConsumer } from './some-consumer'
import { CustomLogger } from './custom.logger'
import { Test } from '@nestjs/testing'
import { mockDeep } from 'jest-mock-extended'

describe('SomeConsumer', () => {
  let someConsumer: SomeConsumer
  const logger = mockDeep<CustomLogger>()

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        SomeConsumer,
        {
          provide: CustomLogger,
          useValue: logger,
        },
      ],
    }).compile()

    someConsumer = module.get(SomeConsumer)
  })

  it('should do something', () => {
    const result = someConsumer.doSomething()

    expect(result).toEqual('something returned')
  })

  it('should log something', () => {
    someConsumer.doSomething()

    expect(logger.log).toHaveBeenCalledWith('something')
  })
})

some-consumer.ts

I figured I would provide an example of the logger being consumed.

import { Injectable } from '@nestjs/common'
import { CustomLogger } from './custom-logger'

@Injectable()
export class SomeConsumer {
  constructor(private readonly logger: CustomLogger) {}

  public doSomething(): string {
    this.logger.log('something')

    return 'something returned'
  }
}

βœ… A Second Try with the imported Logger from @nestjs/common

I saw elsewhere someone mentioning you could set the logger in the module, so I gave it a shot and it seems to work as well πŸŽ‰

some-consumer-imported.ts

import { Injectable, Logger } from '@nestjs/common'

@Injectable()
export class SomeConsumerImported {
  private logger = new Logger(SomeConsumerImported.name)

  public doSomething(): string {
    this.logger.log('something logged')

    return 'something returned'
  }
}

some-consumer-imported.spec.ts

import { SomeConsumerImported } from './some-consumer-imported'
import { Logger } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { mockDeep } from 'jest-mock-extended'

describe('SomeConsumerImported', () => {
  let someConsumerImported: SomeConsumerImported
  const logger = mockDeep<Logger>()

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [SomeConsumerImported],
    }).compile()

    module.useLogger(logger)

    someConsumerImported = module.get(SomeConsumerImported)
  })

  it('should do something', () => {
    const result = someConsumerImported.doSomething()

    expect(result).toEqual('something returned')
  })

  it('should log something', () => {
    someConsumerImported.doSomething()

    expect(logger.log).toHaveBeenCalledWith('something logged', SomeConsumerImported.name)
  })
})

Fire answered 29/3, 2023 at 23:19 Comment(3)
The last option here is promising, however since logger is defined once, whenever I use expect(logger.log).toHaveBeenCalledWith(...) within a test, it fails because it is called multiple times due to other tests also triggering it. – Oligopoly
@ChrisBarr Look into having jest reset your mocks between tests via the configuration jestjs.io/docs/configuration#resetmocks-boolean, or explicitly call .mockReset() on your mock. We have it reset mocks between all tests for our default since that is not normal functionality for whatever reason. I guess it depends how how you like to write your tests. – Fire
Thanks, I was not aware of that. I'm used to testing with Jasmine, so Jest's oddities are a little new to me. – Oligopoly
O
1

A problem is that you should inject the logger, but doing it that way you lose the ability to set the context of the current class name, or set options. So here's what I did to get around this.

I created a new injectable class at src/app-logger.ts that allows me to optionally set the context

import { Injectable, Logger, LoggerService } from '@nestjs/common';

@Injectable()
export class AppLogger implements LoggerService {
  private readonly options = { timestamp: true };
  private context?: string;

  constructor(private readonly logger: Logger) {}

  setContext(contextName: string): void {
    this.context = contextName;
  }

  error(message: unknown): void {
    this.logger.error(message, this.context, this.options);
  }

  warn(message: unknown): void {
    this.logger.warn(message, this.context, this.options);
  }

  log(message: unknown): void {
    this.logger.log(message, this.context, this.options);
  }

  debug(message: unknown): void {
    this.logger.debug(message, this.context, this.options);
  }

  verbose(message: unknown): void {
    this.logger.verbose(message, this.context, this.options);
  }
}

Then in whatever class I want to use it, I inject it and call setContext() in the constructor, and then I can use it like the regular logger anywhere in that class.

@Injectable()
export class TestService {
  constructor(
    private readonly logger: AppLogger,
  ) {
    logger.setContext(TestService.name);
  }
  
  doStuff(): void {
    this.logger.log('hello!');
  }
}

So now when I go to write tests I have this mockAppLogger which I store in src/test-helpers.ts along with other reusable testing methods

export const mockAppLogger: Record<keyof AppLogger, typeof jest.fn> = {
  setContext: jest.fn(),
  error: jest.fn(),
  log: jest.fn(),
  warn: jest.fn(),
  debug: jest.fn(),
  verbose: jest.fn(),
};

So now in my actual test I can just provide that as I would any other injected service

describe('TestService', () => {
  let service: TestService;
  let logger: AppLogger;
  let repo: Repository<TestEntity>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        TestService,
        { provide: AppLogger, useValue: mockAppLogger }, //<-- provide the mock logger here
      ],
    }).compile();

    service = module.get(TestService);
    logger = module.get<AppLogger>(AppLogger);
  });
  
  it('should log a value', () => {
    jest.spyOn(logger, 'log');
    service.doStuff();
    
    expect(logger.log).toHaveBeenCalledWith('hello!');
  });
});
Oligopoly answered 14/11, 2023 at 17:15 Comment(0)
I
0

Mock the module jest.mock('@nestjs/common/services/logger.service');

Ionize answered 24/7 at 16:59 Comment(0)
A
-1

You can just use jest.SpyOn on the Logger class directly

 jest.spyOn(Logger, 'log');

 expect(Logger.Log).toHaveBeenCalledTimes('error', 'SmsService.sendSMS');

Alsatian answered 18/6, 2023 at 7:30 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.