Code with Hugo: abusing Jest snapshot tests, some nice use-cases
Abusing Jest snapshot tests: some nice use-cases πΈ
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 lost 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:
- Config
- Database integration layer (for both setup of and ORM model and queries)
- Server-rendered templates (ie. advanced string manipulation)
Full code is available at github.com/HugoDF/snapshot-everything
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></span> <span class="nv">myTitle</span> <span class="cp"></h1> <p></span> <span class="nv">myText</span> <span class="cp"></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