Hugo Di Francesco

@hugo__df

Abusing Jest snapshot tests: some nice use-cases 📸

Ben Sauer on Unsplash

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:

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 or toEqual 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 all the posts of the week before anyone else in your inbox: Subscribe Now

Originally published at codewithhugo.com on August 10, 2018.

More by Hugo Di Francesco

Topics of interest

More Related Stories