/ #Testing #JavaScript 

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:[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

A developer, working out of London writing CSS, JavaScript and Python.