Making Unit tests easy: How to use mock data factories
Guillaume Barra8 min read
Writing unit tests is (or should be) a large part of our job, so why not make it simpler? When we start writing unit tests for our projects, we’ll be creating a lot of mock data. So much that we sometimes forget that duplication is a code smell, as long as our tests are passing. However, there is a way to keep things simple, that also improves scalability, maintainability, and the overall cleanliness of tests… Let’s look at the factory pattern.
A typical test, and issues with it:
More often than not, we’ll start by running tests on a simple object. In this example we’ll create a Person object, and keep track of basic information as well as the pets owned by this person:
it("returns true if the person has pets", () => {
const person1: Person = {
name: "Guillaume Barra",
pets: 1,
};
expect(checkPersonHasPets(person1)).toBe(true);
});
For now, this is completely fine, but if we want to test against false positives, we have to rewrite it with a slightly modified object, this is the first occurrence of duplication in the code.
it("returns false if the person has no pets", () => {
const person1: Person = {
name: "Guillaume Barra",
pets: 0,
};
expect(checkPersonHasPets(person1)).toBe(false);
});
This is not too bad for now as the object is still very simple, but as fields and tests get added, the waste will grow exponentially.
This raises several issues:
- Our test files will get overwhelmed by mock data
- We have to go back to every test we wrote to add the missing fields every time some information is added
- We have to write a full person object each time, even though most of the fields will not be relevant to our unit test
Let’s explore solutions:
One of the first ideas we could have in order to keep our test files cleaner, could be to move all the mock data to their own files. However, this does nothing but move the problem elsewhere. If we struggled to find a specific test in our file before, we’ll be able to find the test now, but we’ll still have to run through our mock files and find the data used in the test if we encounter issues.
Realising we’ve mocked extremely similar data multiple times should be a trigger for re-using most of that mock data throughout our tests:
Introducing the spread operator:
Let’s start by creating a (slightly larger) default object that we’ll re-use:
const defaultPerson: Person = {
name: 'Guillaume Barra',
dob: new Date('2000-12-10')
city: 'London'
pets: 1,
};
Now, how do we use this if we need only one field to be different than the default in our other tests:
it("returns false when a person has no pets", () => {
const personWithNoPets: Person = {
...defaultPerson,
pets: 0,
};
// rest of test here
});
By using the spread operator on the default object, we allow ourselves to change any values we need for our test. Another advantage of this, is that by having the default at the top of our file, we have an easily accessible reference of what our data should look like.
The small issue that remains here, is scalability; how do we apply this to ever-growing objects and more and more complex tests? With this method, we’ll keep copying the same snippet of code, sometimes without ever making a change to it, over and over again. And we haven’t even looked at tests that include more than one mock object yet… There is a better solution!
The factory pattern:
Using this pattern allows us to write a function that will create data for us in our tests, following this structure of default object.
const createMockPerson = (partialData?: Partial<Person>): Person => ({
name: 'Guillaume Barra',
dob: new Date('2000-12-10')
city: 'London'
pets: 1,
...partialData,
});
We can then use this in our tests as follows:
it("returns false when a person has no pets", () => {
const personWithNoPets = createMockPerson({ pets: 0 });
// rest of test here
});
Having a factory function makes use of some cool TypeScript features, the main one being Partial<Type>
, the way this works is by ensuring that the property provided is defined on the type it is a partial of. By coupling this with the spread operator, it allows us to pass in data that matches one or more fields of the object, and apply them instead of the default values defined in the function.
This has all the advantage of simply defining a default object, and improves scalability and reusability. Creating new data in every test using this method also allows us to ensure that we start off with clean data every time, which coupled with the type safety of this method lets us avoid more bugs. This is great, but let’s look at a more complex case.
In some cases, we might have nested objects, which would complicate the use of the factory if we want to edit data that is within one of the nested objects. Thankfully, this method can easily be adapted, and we can use factory functions within one another in order to have access to all the properties of the object and its children:
const defaultPerson: Person = {
name: "Guillaume Barra",
dob: new Date("2000-12-10"),
city: "London",
pets: [
{
animal: "Cat",
name: "Steve",
},
],
};
Having an object defined as follows can make it hard to edit the data associated with pets, so what can we do about this?
Make a function to do it for us!
const createMockPet = (partialData?: Partial<Pet>): Pet => ({
animal: 'Cat',
name: 'Steve'
...partialData,
});
We can then embed this into our first factory:
const createMockPerson = (partialData?: Partial<Person>): Person => ({
name: 'Guillaume Barra',
dob: new Date('2000-12-10')
city: 'London'
pets: createMockPet(partialData?.pets),
...partialData,
});
This is a lot better but it raises one last issue: None of our data is unique, and this becomes a lot worse if we create multiple copies of an object in a test! This can be a problem if we ever encounter an issue with our mock data, tests can fail and we won’t know which object is causing it. Making each object unique makes our life a lot easier when fixing tests. Let’s introduce unique IDs in our factory and see how we can facilitate creating multiple objects, with those unique IDs:
In the case of creating one object, we can simply add an ID field to that object, and pass in an ID when using the factory:
const createMockPerson = (id: number, partialData?: Partial<Person>): Person => ({
id: 'person-id-${id}',
name: 'Guillaume Barra',
dob: new Date('2000-12-10')
city: 'London'
pets: createMockPets(id, partialData?.pets),
...partialData,
});
const createMockPet = (id: number, partialData?: Partial<Pet>): Pet => ({
id: 'pet-id-${id}',
animal: 'Cat',
name: 'Steve'
...partialData,
});
//Use the function with an ID:
createMockPerson(1)
This creates a Person object with all the default values, and an ID ‘person-id-1’, this person has one pet with the ID ‘pet-id-1’
How can we use this when creating more than one object? Let’s say for our example we want to create one person with several pets
createMockPerson(1, {
[
{
animal: 'Cat',
name: 'Steve',
},
{
animal: 'Dog',
name: 'Peach',
},
],
});
Doing this re-introduces the duplication we’ve been trying so hard to avoid, let’s create one last function to make our life easier!
const createMockPets = (nPets: number): Pets[] => {
return Array(nPets)
.fill(null)
.map((_, i) => createPet(i + 1));
};
This function takes in the amount of data we want to create, and will call the initial function that amount of times, filling up an array with the generated objects (each of them having their own unique ID).
When this is set up, we can use this to create a Person object with multiple pets:
createMockPerson(1, { pets: createMockPets(2) });
To sum it up:
The factory pattern allows us, with minimal setup work, to be able to use reliable and scalable mock data in all of our unit tests. This is a way of gaining time when writing our tests, but also makes testing more approachable after everything’s been set up.
Not only should it make your codebase more maintainable, it should also help keep track of all the data types being used, whilst not having to think of them every time we want to write a new test.
If you want to take it further, the factory pattern can be applied to any constructor, and is not only useful for testing. Here is a good article to find out about other use cases: https://refactoring.guru/replace-constructor-with-factory-method.
It’s very easy to get creative with factory functions and create almost any type of object, the flexibility and ease of use is definitely a huge plus, and hopefully a reason to start using them on your projects!