Testing Patterns and Strategies - 1. Mock Factories

Testing Patterns and Strategies - 1. Mock Factories

Back

Mock factories are a simple and ubiquitous pattern. Despite this, I rarely see them leveraged to their full potential. It’s an underappreciated pattern that, when implemented properly, can multiply the usefulness of your tests

What problem does this pattern solve?

A mock factory will streamline your tests by giving you a quick, concise way to generate test data. That alone is reason enough to use this pattern

A good mock factory will spit out random test data. This is powerful when used as an input for your tests. Random test inputs will cover more ground uncovering more bugs, making your tests much more useful

A great mock factory will make this random test data reproducible. Making bugs easy to reproduce saves time, and improves developer experience

How do you implement this pattern?

Deterministic mock factory

To begin, we’ll create a simple deterministic mock factory. First we’ll create a “default” chunk of test data

const defaultUser: User = {
  id: "00000000-0000-0000-0000-000000000000",
  fullName: "John Doe",
  userName: "j.doe.123",
  email: "john.doe@test.com",
  role: "Admin",
};

Next we’ll write a function that will spit out this “default” test data

const mockFactoryUser = (user?: Partial<User>): User => ({
  ...defaultUser,
  ...user,
});

Notice how the function has a parameter that’s an optional partial. In the return we’re spreading this data after our “default” data, so any fields we specify will be returned 1

Using this function we can write a single expression to generate the exact data we want. For example, if we want to create test data for a specific User role, we could write something like this

const guest = mockFactoryUser({ role: "Guest" });

/* Output
 *
 * {
 *   id: "00000000-0000-0000-0000-000000000000",
 *   fullName: "John Doe",
 *   userName: "j.doe.123",
 *   email: "john.doe@test.com",
 *   role: "Guest",
 * }
 */

Now we have a concise easy way of generating custom test data

A simple deterministic mock factory like this can go a long way in cleaning up your tests, but we can achieve a lot more with a little extra effort

Random mock factory

Static test data is fine, but it’s limitied in it’s usefulness. A failed test only tells us about regression bugs, it doesn’t tell us anything new. A big improvement we can make is to use random mock data

Random mock data is powerful when used as inputs for our tests. By inputting random data we cover new ground each time our test is run, which increases the likelihood we’ll uncover more bugs

For example, consider a test that consumes a random phone number. Each time our test runs it will hit our test with a new phone number format. If there’s a bug related to phone number formatting, eventually it will be teased out

To demonstrate this pattern we’ll extend our last example to create a random mock factory

For this we will need a library to generate our random data. Here I will use Faker, but there are plenty of libraries out there to choose from

const defaultUser: User = {
  id: faker.string.uuid(),
  fullName: faker.person.fullName(),
  userName: faker.internet.username(),
  email: faker.internet.email(),
  role: faker.helpers.arrayElement([
    "Admin",
    "Editor",
    "Writer",
    "Guest"
  ]),
};

Here you can see we’re initialising the object with random data, using the exact format we want. We don’t need to make any changes to our original function

Now if we call our mock factory again, you will see that the data is random. Notice how we can still specify specific values for our mock data

const guest = mockFactoryUser({ role: "Guest" });

/* Output
 *
 * {
 *   id: "3e0f88f1-0145-486c-80b9-757e8f0409c0",
 *   fullName: "Mr. Mickey Macaroni",
 *   userName: "__elite-gamer-360__",
 *   email: "Kassandra+test@gmail.com",
 *   role: "Guest",
 * }
 */

Now we have a way of generating random test data

We’ve drastically improved the usefulness of our tests, but what happens when new bugs are discovered? Presumably we want to fix them, but replicating the failed test is not possible

Not a great developer experience right? Well we can fix that with one more improvement

Random data seed

To make our failed tests reproducible we can use a seed

It sounds obvious, but to implement seeds we first need to make sure our random data library supports seeding. Faker (the library we’re using) does support seeding

Next we need a way to set this seed. There’s a few different ways you can approach this, but I find using an environment variable works best. You can have something like this

const seed = parseInt(process.env.SEED || "0");

faker.seed(seed);

Then in your pipeline you could include a random seed generation step. Here’s an example of what this might look like in a Github workflow 2

name: Test

# . . .

jobs:
  test:
    steps:
      # . . .

      - name: Generate random seed
        run: echo "SEED=$RANDOM" >> $GITHUB_ENV

      - name: Test
        run: npm run test

      # . . .

Now when a test fails we have an easy way to replicate the failure. You can find the seed value in your pipeline logs, then you can copy this seed value into your .env file to replicate the failed tests locally

Final thoughts

With most software patterns, I rarely find an opportunity to use them. They’re often a specialised tool in my toolbelt, only taken out for very niche problems

The mock factory pattern does not fall into this camp. It’s a trusty hammer I use on every project because it always comes in handy

If you spend any time writing tests I highly recommend you add this tool to your toolbelt. You will find your tests become a lot more streamlined, and the usefulness of your tests will start to multiply

Footnotes

  1. This is plain TypeScript so the spread operator will only merge top level fields. If there are nested fields in the object you should use a library such as lodash.merge or deepmerge to do a deep merge of the two objects

  2. For this to work your pipeline needs to have a way for you to see environment variable values. Github workflows work well because the environment is logged on each step