When to use Jest snapshot tests: comprehensive use-cases and examples ๐ธ
There’s some nice use-cases for snapshot tests outside of the well-travelled React/Vue UI component ones.
In other words, although React and Vue testing with snapshots is pretty well documented, that’s not the only place they’re useful.
As a rule of thumb, you could replace a lot of unit tests that assert on with specific data with snapshot tests.
We have the following pros for snapshot tests:
- the match data is stored in a separate file so it’s harder to lose track of things, eg. being skimmed over during review
- it’s a lot less effort to change than inline data matching, just run
npx jest -u
and all snapshots get updated.
The following cons also come to mind:
- it’s a lost less effort to change than inline data matching, which means people need to pay attention to changes in snapshot files
- despite community efforts, the only major test library that supports out of the box is Jest (which locks you into that ecosystem)
That makes it particularly well-suited for a couple of areas:
Table of Contents
Full code is available at github.com/HugoDF/snapshot-everything.
This was sent out on the Code with Hugo newsletter last Monday. Subscribe to get the latest posts right in your inbox (before anyone else).
Config ๐
monitor-queues.test.js
:
jest.mock('bull-arena');
const { monitorQueues } = require('./monitor-queues');
describe('monitorQueues', () => {
test('It should return an Arena instance with parsed data from REDIS_URL', () => {
const redisPort = 5555;
const REDIS_URL = `redis://h:passsssword@hosting:${redisPort}/database-name`;
const QUEUE_MONITORING_PATH = '/arena';
const ArenaConstructor = require('bull-arena');
ArenaConstructor.mockReset();
monitorQueues({ REDIS_URL, QUEUE_MONITORING_PATH });
expect(ArenaConstructor).toHaveBeenCalledTimes(1);
expect(ArenaConstructor.mock.calls[0]).toMatchSnapshot();
});
test('It should return an Arena instance with defaulted redis data when REDIS_URL is empty', () => {
const REDIS_URL = '';
const QUEUE_MONITORING_PATH = '/arena';
const ArenaConstructor = require('bull-arena');
ArenaConstructor.mockReset();
monitorQueues({ REDIS_URL, QUEUE_MONITORING_PATH });
expect(ArenaConstructor).toHaveBeenCalledTimes(1);
expect(ArenaConstructor.mock.calls[0]).toMatchSnapshot();
});
});
monitor-queues.js
:
const Arena = require('bull-arena');
const { JOB_TYPES } = require('./queue/queues');
const url = require('url');
function getRedisConfig (redisUrl) {
const redisConfig = url.parse(redisUrl);
return {
host: redisConfig.hostname || 'localhost',
port: Number(redisConfig.port || 6379),
database: (redisConfig.pathname || '/0').substr(1) || '0',
password: redisConfig.auth ? redisConfig.auth.split(':')[1] : undefined
};
}
const monitorQueues = ({ REDIS_URL, QUEUE_MONITORING_PATH }) =>
Arena(
{
queues: [
{
name: JOB_TYPES.MY_TYPE,
hostId: 'Worker',
redis: getRedisConfig(REDIS_URL)
}
]
},
{
basePath: QUEUE_MONITORING_PATH,
disableListen: true
}
);
module.exports = {
monitorQueues
};
Gives the following snapshots:
exports[`monitorQueues It should return an Arena instance with defaulted redis data when REDIS_URL is empty 1`] = `
Array [
Object {
"queues": Array [
Object {
"hostId": "Worker",
"name": "MY_TYPE",
"redis": Object {
"database": "0",
"host": "localhost",
"password": undefined,
"port": 6379,
},
},
],
},
Object {
"basePath": "/arena",
"disableListen": true,
},
]
`;
exports[`monitorQueues It should return an Arena instance with parsed data from REDIS_URL 1`] = `
Array [
Object {
"queues": Array [
Object {
"hostId": "Worker",
"name": "MY_TYPE",
"redis": Object {
"database": "database-name",
"host": "hosting",
"password": "passsssword",
"port": 5555,
},
},
],
},
Object {
"basePath": "/arena",
"disableListen": true,
},
]
`;
Database Models ๐ฌ
Setup ๐
test('It should initialise correctly', () => {
class MockModel { }
MockModel.init = jest.fn();
jest.setMock('sequelize', {
Model: MockModel
});
jest.resetModuleRegistry();
const MyModel = require('./my-model');
const mockSequelize = {};
const mockDataTypes = {
UUID: 'UUID',
ENUM: jest.fn((...arr) => `ENUM-${arr.join(',')}`),
TEXT: 'TEXT',
STRING: 'STRING'
};
MyModel.init(mockSequelize, mockDataTypes);
expect(MockModel.init).toHaveBeenCalledTimes(1);
expect(MockModel.init.mock.calls[0]).toMatchSnapshot();
});
my-model.js
:
const { Model } = require('sequelize');
class MyModel extends Model {
static init (sequelize, DataTypes) {
return super.init(
{
disputeId: DataTypes.UUID,
type: DataTypes.ENUM(...['my', 'enum', 'options']),
message: DataTypes.TEXT,
updateCreatorId: DataTypes.STRING,
reply: DataTypes.TEXT
},
{
sequelize,
hooks: {
afterCreate: this.afterCreate
}
}
);
}
static afterCreate() {
// do nothing
}
}
module.exports = MyModel;
Gives us the following snapshot:
exports[`It should initialise correctly 1`] = `
Array [
Object {
"disputeId": "UUID",
"message": "TEXT",
"reply": "TEXT",
"type": "ENUM-my,enum,options",
"updateCreatorId": "STRING",
},
Object {
"hooks": Object {
"afterCreate": [Function],
},
"sequelize": Object {},
},
]
`;
Queries ๐
my-model.test.js
:
jest.mock('sequelize');
const MyModel = require('./my-model');
test('It should call model.findOne with correct order clause', () => {
const findOneStub = jest.fn();
const realFindOne = MyModel.findOne;
MyModel.findOne = findOneStub;
const mockDb = {
Association: 'Association',
OtherAssociation: 'OtherAssociation',
SecondNestedAssociation: 'SecondNestedAssociation'
};
MyModel.getSomethingWithNestedStuff('1234', mockDb);
expect(findOneStub).toHaveBeenCalled();
expect(findOneStub.mock.calls[0][0].order).toMatchSnapshot();
MyModel.findOne = realFindOne;
});
my-model.js
:
const { Model } = require('sequelize');
class MyModel extends Model {
static getSomethingWithNestedStuff(match, db) {
return this.findOne({
where: { someField: match },
attributes: [
'id',
'createdAt',
'reason'
],
order: [[db.Association, db.OtherAssociation, 'createdAt', 'ASC']],
include: [
{
model: db.Association,
attributes: ['id'],
include: [
{
model: db.OtherAssociation,
attributes: [
'id',
'type',
'createdAt'
],
include: [
{
model: db.SecondNestedAssociation,
attributes: ['fullUrl', 'previewUrl']
}
]
}
]
}
]
});
}
}
Gives the following snapshot:
exports[`It should call model.findOne with correct order clause 1`] = `
Array [
Array [
"Association",
"OtherAssociation",
"createdAt",
"ASC",
],
]
`;
pug or handlebars templates
This is pretty much the same as the Vue/React snapshot testing stuff, but letโs walk through it anyways, we have two equivalent templates in Pug and Handlebars:
template.pug
:
section
h1= myTitle
p= myText
template.handlebars
:
<section>
<h1>{{ myTitle }}</h1>
<p>{{ myText }}</p>
</section>
template.test.js
:
const pug = require('pug');
const renderPug = data => pug.renderFile('./template.pug', data);
test('It should render pug correctly', () => {
expect(renderPug({
myTitle: 'Pug',
myText: 'Pug is great'
})).toMatchSnapshot();
});
const fs = require('fs');
const Handlebars = require('handlebars');
const renderHandlebars = Handlebars.compile(fs.readFileSync('./template.handlebars', 'utf-8'));
test('It should render handlebars correctly', () => {
expect(renderHandlebars({
myTitle: 'Handlebars',
myText: 'Handlebars is great'
})).toMatchSnapshot();
});
The bulk of the work here actually compiling the template to a string with the raw compiler for pug and handlebars.
The snapshots end up being pretty straightforward:
exports[`It should render pug correctly 1`] = `"<section><h1>Pug</h1><p>Pug is great</p></section>"`;
exports[`It should render handlebars correctly 1`] = `
"<section>
<h1>Handlebars</h1>
<p>Handlebars is great</p>
</section>
"
`;
Gotchas of snapshot testing โ ๏ธ
Some things (like functions) donโt serialise nicely ๐ข
See in __snapshots__/my-model.test.js.snap
:
"hooks": Object {
"afterCreate": [Function],
},
We should really add a line like the following to test that this function is actually the correct function, (my-model.test.js
):
expect(MockModel.init.mock.calls[0][1].hooks.afterCreate).toBe(MockModel.afterCreate);
If you can do a full match, do it
A lot of the time, a hard assertion with an object match is a good fit, don’t just take a snapshot because you can.
You should take snapshots for things that pretty much aren’t the core purpose of the code, eg. strings in a rendered template, the DOM structure in a rendered template, configs.
The tradeoff with snapshots is the following:
A snapshot gives you a weaker assertion than an inline
toBe
ortoEqual
does, but it’s also a lot less effort in terms of code typed and information stored in the test (and therefore reduces complexity).
Try to cover the same code/feature with another type of test โ๏ธ
Whether that’s a manual smoke test that /arena
is actually loading up the Bull Arena queue monitoring, or integration tests over the whole app, you should still check that things work ๐.
Full code is available at github.com/HugoDF/snapshot-everything.
Get The Jest Handbook (100 pages)
Take your JavaScript testing to the next level by learning the ins and outs of Jest, the top JavaScript testing library.
orJoin 1000s of developers learning about Enterprise-grade Node.js & JavaScript