DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 967,611 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
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

Latest comments (1)

Collapse
 
annarankin profile image
Anna Rankin

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

Thank you.

Thanks for visiting DEV, we’ve worked really hard to cultivate this great community and would love to have you join us. If you’d like to create an account, you can sign up here.