Abusing Jest snapshot tests: some nice use-cases 📸

Written by hugo__df | Published 2018/08/13
Tech Story Tags: javascript | jest | testing | developer | web-development

TLDRvia the TL;DR App

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:

sectionh1= myTitlep= 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.


Published by HackerNoon on 2018/08/13