I have a logger which utilizes the Winston NodeJs package. The logger does additional logic, and I would like to have unit tests to ensure that the proper data is being passed to Winston. However, since I have set up external transports (such as Firehose) I do not need those to be invoked.
I am not passing in Winston as a dependency through the constructor, but I have tried stubbing the createLogger method, the log method, and Winston as a whole as I normally would when stubbing a dependency.
The createStubbedInstance method does not work with Winston (or, I have not been able to get it to work) due to the fact that Winston is not exported as a class, but as a namespace.
import { Logger, ILoggerConfig } from './src';
import * as winston from 'winston'
describe('Logger', () => {
let loggerConfig: ILoggerConfig;
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
loggerConfig = {
correlationId: faker.random.uuid(),
action: 'GET',
};
sandbox = sinon.createSandbox();
winstonStub = sandbox.stub(winston);
winstonStub.createLogger.resolves();
winstonStub.log.resolves();
...
});
it('should log with INFO log level', () => {
const logger = new Logger(loggerConfig);
logger.info('Hello there!');
sinon.assert.calledOnce(winstonStub.log);
sinon.assert.calledWith(winsonStub.log, sinon.match.has("level", 'info'))
});
import { Logger, ILoggerConfig } from './src';
import * as winston from 'winston'
describe('Logger', () => {
let loggerConfig: ILoggerConfig;
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
loggerConfig = {
correlationId: faker.random.uuid(),
action: 'GET',
};
sandbox = sinon.createSandbox();
winstonStub = sandbox.stub(winston, 'createLogger').resolves({ log: sanbox.stub() });
...
});
it('should log with INFO log level', () => {
const logger = new Logger(loggerConfig);
logger.info('Hello there!');
sinon.assert.calledOnce(winstonStub);
});
I would expect to be able to assert that the stub would be called a certain number of times. However, the stub always has a call count of 0, and I get an error indicating that Winston cannot post to Firehose due to permission issues. I also have the Console transport set up, and still see logs in the console when I should not.
I couldn't use "esModuleInterop": true with import winston from 'winston' due to strict settings of the project causing a ripple effect of compilation issues.
Luckily I found a clean way to mock what I needed when mocking log directly failed.
import * as tape from "tape";
import * as sinon from "sinon";
import * as winston from "winston";
tape('Stubbing winston', (test) => {
test.test('Or at least part of it', (test) => {
const logSpy = sinon.spy()
sinon
.stub(winston, "createLogger")
.callsFake(() => ({
log: logSpy,
} as unknown as Logger));
const logger = new MyLoggerThatUsesWinston()
logger.log('Only lost');
logger.log('four hours');
logger.log('on this');
test.equal(logSpy.callCount, 3, "")
test.end();
}
test.end();
}
Related
I'm new in nodejs, I'm using fastify and I want to be able to use the req.logger in all the classes functions of the flow, this because I have a the request-id on req.logger, the first solution that came to my mind is to pass as a parameter the logger through all the function/classes but I think that would make the code kind of dirty, here is an example of my code:
app.ts
import pino from 'pino';
import fastify from 'fastify';
declare module 'fastify' {
interface FastifyInstance {
// augment fastify instance with the config object types
config: Config;
}
}
function build() {
const app = fastify({
logger: pino({
name: process.env.NAME,
level: process.env.LOG_LEVEL,
}),
disableRequestLogging: true,
requestIdHeader: 'correlation-id',
requestIdLogLabel: 'correlationId',
});
// register plugins
app.register(apiRoutes, fastify => ({
getObjectUseCase: new GetObjectUseCase(
new TestClass()),
}));
return app;
}
export { build };
routes.ts
import { FastifyPluginCallback } from 'fastify';
import { StatusCodes } from 'http-status-codes';
export const apiRoutes: FastifyPluginCallback<RoutesOpts> = async (fastify, options, done) => {
const getObjectUseCase = options.getObjectUseCase;
fastify.get<object>('/v1/api/:id', async (req, reply) => {
const id = req.params.payoutId;
req.logger.info('This is a logger print'); // has the correlation id inside it while printing
const storedObject = await getObjectCase.execute(id);
reply.code(StatusCodes.OK).send(storedObject);
});
}
GetObjectUseCase.ts
export class GetObjectUseCase {
private anotherClass: TestClass;
constructor(anotherClass: TestClass) {
this. anotherClass = anotherClass;
}
async execute(id: string): Promise<StoredObject> {
// I want to use the logger here with have the correlation id on it without having to pass it as an argument on the method, how is it posible?
return this.anotherClass.getById(id);
// also needed to use it inside anotherClass.getById so I will need to pass the logger also in the method
}
}
Hope I have been clear.
Thanks!
This may not be the best or only way to do it, but this has worked for me in the past.
Typically I structure my projects with an app.ts that just instantiates my FastifyInstance and then exports the log from that created instance. This allows me to use the log where ever I want to.
It looks something like this.
app.ts
import fastify from 'fastify';
const app = fastify({ logger: true /* Your logging configuration */});
export default app;
export const logger = app.log; // Allows me to log where ever I want.
server.ts
import app from './app';
... // All your fastify configuration and other stuff.
app.listen({ ... });
Now I can use the logger outside of fastify stuff.
get-object-use-case.ts
import { logger } from './app'; // Import your fastify logger to use in this class.
export class GetObjectUseCase {
private anotherClass: TestClass;
constructor(anotherClass: TestClass) {
this. anotherClass = anotherClass;
}
async execute(id: string): Promise<StoredObject> {
logger.info({/* Whatever you want to log here. */}); // Now you can use the logger here.
return this.anotherClass.getById(id); // You can just import the logger into the TestClass file to get logging enabled there.
}
}
This even allows you to log before your FastifyInstance is started. Check out this codesandbox for a running example.
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"
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:
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.
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:
Jest loads Logger.test.ts
Node module loader loads all the modules imported with import ... from ...
Logger.ts is executed, preceded by with import { createLogger } from 'winston'.
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', () => {
...
I have a logger file as below which implements logging functionality. uuidLogger.js
const winston = require('winston'),
CustomTransport = require('./customTransport');
function getLogger(route) {
return winston.createLogger({
defaultMeta: { route },
transports: [new CustomTransport()]
});
}
module.exports = getLogger;
It is imported by a function like this and used for logging testfn.js
const uuidLogger = require('./uuidLogger')('test-fn');
function testMock() {
uuidLogger.info('Hey I am just logging');
}
module.exports = { testMock };
I am trying to mock uuidlogger in testfn.js so that I can track various methods called on uuidLogger object. I tried below approach.
import { testMock } from './testfn';
import getLogger from './uuidLogger';
const logger = getLogger('testfn');
jest.mock('./uuidLogger', () =>
jest.fn(() => ({
info: jest.fn(() => console.log('Mocked function actually called'))
}))
);
it('verify that info method was called on returned object', () => {
testMock('abx');
expect(logger.info).toBeCalledTimes(1);
});
It was able to mock the method called however mock information is not getting reflected in logger.info object.
I also tried below approach
import { testMock } from './testfn';
import getLogger from './uuidLogger';
jest.mock('./uuidLogger', () =>
jest.fn(() => ({ info: jest.fn(() => console.log('Top level fn')) }))
);
const logger = {
error: jest.fn(),
info: jest.fn(() => {
console.log('Overwritten fn');
})
};
getLogger.mockReturnValue(logger);
it('shud return Winston instance', () => {
testMock('abx');
expect(logger.info).toBeCalledTimes(1);
});
Any help on how to get it will be appreciated. Thanks in advance.
It seems to be the assertion is not done on proper variable.
Need to assert on getLogger
Your first approach of writing test case is proper.
Add assertion something like this:
expect(getLogger.mock.results[0].value.info).toBeCalledTimes(1);
I am writing unit tests for a node application using Jest.
The Node code is using a third party library to log information.
The library has a function getLogger which you should call to return a logger object.
I am trying to mock the calls for that library and detect its calls in my unit test.
The node code is as follows:
const logger = require('third-party-libary').getLogger('myModule')
....
function submitSomething() {
....
logger.info('log something')
}
In my Jest unit test, I tried to mock those logger calls in many different ways, with no success, and always come back as "logger is not defined"
I tried:
jest.mock('third-party-library');
const loggerFactory = require('third-party-library');
const logger = {
error: jest.fn(),
info: jest.fn()
};
loggerFactory.getLogger.mockImplementation(() => logger);
But it always return error :
cannot find "info" for null object
I tried this as well:
jest.mock('third-party-library')
const loggerFactory = require('third-party-library');
const logger = {
error: jest.fn(),
info: jest.fn()
};
loggerFactory.getLogger = () => logger
I tried this:
jest.mock('third-party-library')
const loggerFactory = require('third-party-library');
const logger = {
error: jest.fn(),
info: jest.fn()
};
loggerFactory.getLogger = jest.fn(() => logger)
With the same error
I switched between the jest.mock to make it after the require, with no luck
Your approach works fine, just note that your code creates logger as soon as it runs so the mock for getLogger has to be in place before the code is required:
jest.mock('third-party-library');
const loggerFactory = require('third-party-library');
const logger = {
error: jest.fn(),
info: jest.fn()
};
// const { submitSomething } = require('./code'); <= would NOT work here
loggerFactory.getLogger.mockReturnValue(logger);
const { submitSomething } = require('./code'); // <= works here
test('submitSomething', () => {
submitSomething();
expect(logger.info).toHaveBeenCalledWith('log something'); // Success!
});
First of all, I'm new to es6 and jest.
I have a Logger class for instantiate winston and I would like to test it.
Here my code :
const winston = require('winston');
const fs = require('fs');
const path = require('path');
const config = require('../config.json');
class Logger {
constructor() {
Logger.createLogDir(Logger.logDir);
this.logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new (winston.transports.Console)({
format: winston.format.combine(
winston.format.colorize({ all: true }),
winston.format.simple(),
),
}),
new (winston.transports.File)({
filename: path.join(Logger.logDir, '/error.log'),
level: 'error',
}),
new (winston.transports.File)({
filename: path.join(Logger.logDir, '/info.log'),
level: 'info',
}),
new (winston.transports.File)({
filename: path.join(Logger.logDir, '/combined.log'),
}),
],
});
}
static get logDir() {
return (config.logDir == null) ? 'log' : config.logDir;
}
static createLogDir(logDir) {
if (!fs.existsSync(logDir)) {
// Create the directory if it does not exist
fs.mkdirSync(logDir);
}
}
}
exports.logger = new Logger().logger;
export default new Logger();
I would like to test my function createLogDir().
I my head, I think it's a good idea to test the state of fs.existsSync.
If fs.existsSync return false, fs.mkdirSync must be called.
So I try to write some jest test :
describe('logDir configuration', () => {
test('default path must be used', () => {
const logger = require('./logger');
jest.mock('fs');
fs.existsSync = jest.fn();
fs.existsSync.mockReturnValue(false);
const mkdirSync = jest.spyOn(logger, 'fs.mkdirSync');
expect(mkdirSync).toHaveBeenCalled();
});
});
However, I've got an error :
● logDir configuration › default path must be used
Cannot spy the fs.mkdirSync property because it is not a function; undefined given instead
18 | fs.existsSync = jest.fn();
19 | fs.existsSync.mockReturnValue(true);
> 20 | const mkdirSync = jest.spyOn(logger, 'fs.mkdirSync');
21 | expect(mkdirSync).toHaveBeenCalled();
22 | });
23 | });
at ModuleMockerClass.spyOn (node_modules/jest-mock/build/index.js:590:15)
at Object.test (src/logger.test.js:20:28)
Can you help me to debug and test my function please ?
Regards.
The error there is because it is looking for a method called fs.mkdirSync on your logger object, which doesn't exist. If you had access to the fs module in your test then you would spy on the mkdirSync method like this:
jest.spyOn(fs, 'mkdirSync');
However, I think you need to take a different approach.
Your createLogDir function is a static method - meaning that it can only be called on the class, and not on an instance of that class (new Logger() is an instance of the class Logger). Therefore, in order to test that function you need to export the class and not an instance of it, i.e.:
module.exports = Logger;
Then you could have the following tests:
const Logger = require('./logger');
const fs = require('fs');
jest.mock('fs') // this auto mocks all methods on fs - so you can treat fs.existsSync and fs.mkdirSync like you would jest.fn()
it('should create a new log directory if one doesn\'t already exist', () => {
// set up existsSync to meet the `if` condition
fs.existsSync.mockReturnValue(false);
// call the function that you want to test
Logger.createLogDir('test-path');
// make your assertion
expect(fs.mkdirSync).toHaveBeenCalled();
});
it('should NOT create a new log directory if one already exists', () => {
// set up existsSync to FAIL the `if` condition
fs.existsSync.mockReturnValue(true);
Logger.createLogDir('test-path');
expect(fs.mkdirSync).not.toHaveBeenCalled();
});
Note: it looks like you're mixing CommonJS and es6 module syntax (export default is es6) - I would try to stick to one or the other