DEV Community

Cover image for Abusing Jest snapshot tests: some nice use-cases 📸
Hugo Di Francesco
Hugo Di Francesco

Posted on • Originally published at codewithhugo.com on

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 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(); }); }); 
Enter fullscreen mode Exit fullscreen mode

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 }; 
Enter fullscreen mode Exit fullscreen mode

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, }, ] `; 
Enter fullscreen mode Exit fullscreen mode

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(); }); 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

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 {}, }, ] `; 
Enter fullscreen mode Exit fullscreen mode

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; }); 
Enter fullscreen mode Exit fullscreen mode

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'] } ] } ] } ] }); } } 
Enter fullscreen mode Exit fullscreen mode

Gives the following snapshot:

exports[`It should call model.findOne with correct order clause 1`] = ` Array [ Array [ "Association", "OtherAssociation", "createdAt", "ASC", ], ] `; 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

template.handlebars:

<section> <h1>{{ myTitle }}</h1> <p>{{ myText }}</p> </section> 
Enter fullscreen mode Exit fullscreen mode

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(); }); 
Enter fullscreen mode Exit fullscreen mode

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> " `; 
Enter fullscreen mode Exit fullscreen mode

Gotchas of snapshot testing ⚠️

Some things (like functions) don’t serialise nicely 🔢

See in __snapshots__ /my-model.test.js.snap:

"hooks": Object { "afterCreate": [Function], }, 
Enter fullscreen mode Exit fullscreen mode

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); 
Enter fullscreen mode Exit fullscreen mode

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.

Ben Sauer

Top comments (1)

Collapse
 
annarankin profile image
Anna Rankin

Oh, this is a fun exploration! Never thought of using snapshots like this.