/ #jest #Testing 

When to use Jest snapshot tests: comprehensive use-cases and examples ๐Ÿ“ธ

Curious about Advanced Jest Testing Features?

Want to level up your JavaScript testing fundamentals and leverage Jest's best features?

The Jest Handbook is for you.

I want this

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:[email protected]:${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.

unsplash-logoBen Sauer

Author

Hugo Di Francesco

Co-author of "Professional JavaScript" with Packt. He runs the Code with Hugo website helping over 100,000 developers every month and holds an MEng in Mathematical Computation from University College London (UCL). He has used JavaScript extensively to create scalable and performant platforms at companies such as Canon and Elsevier.

Curious about Advanced Jest Testing Features?

Want to level up your JavaScript testing fundamentals and leverage Jest's best features? The Jest Handbook is for you.