šŸ³
Published on

Using a builder pattern for tests

Authors

What is a builder for test data?

It's a way to create test data with sensible defaults. Something like:

const buildPerson = (overrides) => {
  const defaultPerson = {
    id: guid(),
    firstName: 'Ada',
    lastName: 'Lovelace',
    address: '1 Computer Avenue',
    phoneNumber: '123',
  }
  return { ...defaultPerson, ...overrides }
}

The above is a simplistic example. You'll almost certainly have nested data structures at some point, at which point it might look more like:

const buildAddress = (overrides) => {
  const defaultAddress = {
    id: guid(),
    line1: '1 Computer Avenue',
    line2: 'Somewhere',
    postCode: 'abc123',
  }
  return { ...defaultAddress, ...overrides }
}

const buildPerson = (overrides) => {
  const defaultPerson = {
    id: guid(),
    firstName: 'Ada',
    lastName: 'Lovelace',
    address: buildAddress({ postCode: 'bad data' }),
    phoneNumber: '123',
  }
  return { ...defaultPerson, ...overrides }
}

Why have a builder for test data?

The main reason I prefer the builder pattern is because it helps the readability (and therefore maintainability) of the tests.

With:

it('should capitalise first name', () => {
  const person = buildPerson({ name: 'sue' })
  // do things
  expect(result.person.name).to.equal('Sue')
})

Itā€™s much easier to see what setup data is important to the test. You can ignore the rest.

With:

it('should capitalise first name', () => {
  const person = require('../data/person')
  // do things
  expect(result.person.name).to.equal('Sue')
})

Itā€™s hard to see what data is important, so tests are much harder to read.

The builder avoids you having to have:

it('should capitalise first name', () => {
  const person = {
    name: 'sue',
    lots: 'of',
    other: 'things',
    you: 'just',
    do: 'not',
    care: 'about',
  }
  // do things
  expect(result.person.name).to.equal('Sue')
})

Of course you could do:

it('should capitalise first name', () => {
  const person = { ...require('../data/person'), name: 'sue' }
  // do things
  expect(result.person.name).to.equal('Sue')
})

But

  • itā€™s not as neat
  • It increases unintended coupling between tests e.g. your person file will start including address because the address tests need it even though you donā€™t care about it and may start to include weird edge cases that arenā€™t relevant. The file gets big and you keep having to refer back to it and sift through info you donā€™t care about. The definition of what the ā€˜minimumā€™ version of the data is becomes murky.
  • You canā€™t absorb any complexity from your tests. E.g. maybe when you create a person they automatically get a guid , or maybe if you instantiate them with a birthday older than X they automatically get a canDrink: true field.
  • Iā€™m personally a big fan of giving tests their own data. I donā€™t like it when all the tests use the same userId , same name, same dates, because you end up with less diverse test data and therefore youā€™re testing fewer scenarios.