Testing the logging behaviour of your project may be useful to:
-
Check for regressions to log messages that have dependencies
For example, a particular message may serve as an important audit log, and changing its format could break dashboards and reporting.
-
Visualise the overall logging output of a given section of code
It can be hard to keep track of what logs are actually emitted in a request flow, between your own logic, third-party packages, and middleware. Having a holistic picture of the underlying logging calls may allow you to optimise the usability of your logs and reduce duplication.
Previously, the de facto testing pattern was to set up mocks or spies manually. This may be sufficient to run targeted assertions in a pinch.
import createLogger from '@seek/logger';
export const logger = createLogger();
import { logger } from './logging';
jest.mock('./logging');
const { info } = jest.mocked(logger);
afterEach(info.mockClear);
test('mock', () => {
logger.info({ id: '123' }, 'Something happened');
expect(info).toHaveBeenCalledTimes(1);
expect(info).toHaveBeenNthCalledWith(1, { id: '123' }, 'Something happened');
});
import { logger } from './logging';
const info = jest.spyOn(logger, 'info').mockImplementation(jest.fn);
afterEach(info.mockClear);
test('spy', () => {
logger.info({ id: '123' }, 'Something happened');
expect(info).toHaveBeenCalledTimes(1);
expect(info).toHaveBeenNthCalledWith(1, { id: '123' }, 'Something happened');
});
-
It's a lot of manual fiddling to perform in each project.
-
It's difficult to get a complete picture of logging output.
There are many logging levels and corresponding methods to account for, and it's tedious to mock features such as
logger.child()
. -
It doesn't cover internal processing logic within
@seek/logger
and Pino.For example, the
maxObjectDepth
logger option may reduce the effective effectiveness (ha) of a log message, but alogger.info()
spy would not pick this up.
This feature is ready for adoption, but based on initial feedback, its interface may be revised in a future major version.
@seek/logger
now bundles a convenient mechanism for recording logging calls,
built on Pino's support for customisable destinations.
In practice, this looks like the following:
import createLogger, { createDestination } from '@seek/logger';
const { destination, stdoutMock } = createDestination({
mock: config.environment === 'test',
});
export { stdoutMock };
export const logger = createLogger(
{
// ...
},
destination,
);
import { logger, stdoutMock } from './logging';
afterEach(stdoutMock.clear);
test('stdoutMock', () => {
logger.info({ id: '123' }, 'Something happened');
expect(stdoutMock.onlyCall()).toMatchObject({
id: '123',
level: 30,
msg: 'Something happened',
});
});
Capturing all log properties can be noisy,
especially if you snapshot or assert against an entire log object.
To this end, stdoutMock
redacts and removes some properties by default.
You can extend these defaults:
createDestination({
mock: {
redact: [
...createDestination.defaults.mock.redact,
'additionalPropertyToRedact',
],
remove: [
...createDestination.defaults.mock.remove,
'additionalPropertyToRemove',
],
},
});
Or disable them:
createDestination({
mock: {
redact: [],
remove: [],
},
});
If you were previously manually mocking logging calls in your test environment,
you may have effectively disabled the logger by setting its level to silent
.
This destination-based mechanism requires the logger to be enabled. Set its
minimum level
to something low like trace
or debug
:
const environmentConfigs = {
test: {
- logLevel: 'silent',
+ logLevel: 'debug',
},
// ...
};
You may also need to disable custom transports in your test environment:
createLogger(
{
// ...
transport:
- environment === 'local' || environment === 'test'
+ environment === 'local'
? { target: 'pino-pretty' }
: undefined,
},
destination,
)