Type safe mock data
Often, I find that when I am writing tests, I do not necessarily want to provide robust mock data because it is not necessary for a test.
Suppose we have a User
that looks like:
type User = { firstName: string; lastName: string; age: number; // ... many other fields};
And I wanted to test a function like:
const getFullName = (user: User) => `${user.firstName} ${user.lastName}`;
For our test, we could provide a full mock object:
it('should return full name', () => { const user: User = { firstName: 'John', lastName: 'Smith', age: 15, balance: 12_000, // etc };
const result = getFullName(user);
expect(result).toEqual('John Smith');});
Providing the minimum fields required#
The function only really cares about firstName
and lastName
. So instead, in our test, we could create a mock User
with only the firstName
and lastName
and type assert that it is a valid User
.
it('should return full name', () => { const user = { firstName: 'John', lastName: 'Smith', } as User;
const result = getFullName(user);
expect(result).toEqual('John Smith');});
The advantage of this is that:
- We do not need to provide all the values to form a valid
User
object - We get
IntelliSense
support - When we try to put an invalid data type into a valid key e.g.
firstName: 1
then TypeScript will give us an error
However, if we instantiate the User
object with unknown keys or if the User
type changes so that a key is removed or renamed, TypeScript will not tell us that we are providing invalid keys because we are telling TypeScript that we know that this should be a User
object.
const user = { firstName: 'John', lastName: 'Smith', invalid: 'key',} as User;
Adding type safety with a Partial
type annotation#
A better way of creating our mock object would be to use a type annotation with the Partial utility type as well as the type assertion.
it('should return full name', () => { const user: Partial<User> = { firstName: 'John', lastName: 'Smith', };
const result = getFullName(user as User);
expect(result).toEqual('John Smith');});
Using a type annotation will tell us when we are instantiating an object with invalid keys. The Partial
utility type is used to say that user
only needs to have some of the keys of the User
type. We still need to use a type assertion when passing the user
object to getFullName
because getFullName
expects a User
and not a partial version of User
. We know that getFullName
should only require firstName
and lastName
so we use the type assertion to override TypeScript in this instance.
See the example here.
Adding type safety with the satisfies
operator#
You can also do this using the satisfies operator.
it('should return full name', () => { const user = { firstName: 'John', lastName: 'Smith', } satisfies User as Partial<User> as User;
const result = getFullName(user);
expect(result).toEqual('John Smith');});
In this case, the satisfies User
ensures that we are not instantiating an object with invalid keys and the type assertion as Partial<User> as User
is required to ensure that getFullName
treats the user
object as a full User
.
See the example here.
Using spread syntax to create valid objects every time#
Another approach is to create valid objects every time.
const user: User = { firstName: 'John', lastName: 'Smith', age: 15, credits: 30_000, interests: ['karaoke', 'snowboarding'], // etc};
But this can make it more difficult for the reader to understand what exactly causes the test to pass/fail. One approach to solving this is to use a base mock object with valid data and using spread syntax to fill out the fields that do not matter.
const user: User = { ...baseUser, firstName: 'Jane', lastName: 'Lee',};
A bad implementation of this approach is when the test relies on specific attributes of the base mock object for the test to pass/fail, but it is not obvious in the test case without the reader diving into the base object.
it('should return full name', () => { const user: User = { ...baseUser, firstName: 'Jane', };
const result = getFullName(user);
// Where does 'Smith' come from?? expect(result).toEqual('Jane Smith');});
So, when using this approach you should override all of the relevant attributes that make a test pass/fail even if the test would pass spread from the attributes of the base object anyway.
it('should return full name', () => { const user: User = { ...baseUser, firstName: 'Jane', lastName: 'Lee', };
const result = getFullName(user);
expect(result).toEqual('Jane Lee');});
PartialDeep for nested data#
So far, we have used the User
object as an example which is convenient because it does not have nested fields. This is usually not the case.
type User = { firstName: string; location: { homeTown: string; current: string; };};
The Partial
utility type will now no longer be as useful because Partial
only applies one level deep.
const user: Partial<User> = { location: { homeTown: 'New York', },};
Giving the error:
Property 'current' is missing in type '{ homeTown: "New York"; }' but required in type '{ homeTown: "New York"; current: "Munich"; }'
Instead, we can use PartialDeep from the type-fest
package or define your own:
// credit to https://stackoverflow.com/questions/61132262/typescript-deep-partialtype DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]>; } : T;
DeepPartial
is a variation on Partial
that recursively ensures that all fields of a type are optional. For example, now location.current
is optional:
const user: DeepPartial<User> = { location: { homeTown: 'New York', },};
factory.ts for overriding nested attributes#
If we wanted to create valid objects every time, we will find that spread syntax also does not work well with nesting:
const baseUser: User = { firstName: 'John', location: { homeTown: 'New York', current: 'Munich', },};
const user: Partial<User> = { ...baseUser, location: { homeTown: 'New York', },};
Giving the following error:
Property 'current' is missing in type '{ homeTown: string; }' but required in type '{ homeTown: string; current: string; }'
We could spread for every nested property we want to override:
const user: Partial<User> = { ...baseUser, location: { ...baseUser.location, homeTown: 'New York', },};
This would work, but quickly becomes overly cumbersome. I have found that factory.ts offers a very clean way of modifying nested attributes while also easing the creation of new isolated test data for each test.
For each type we want to create mock data for, we create a Factory
with the default values we want for our mock data similar to the base object we had before.
import { makeFactory } from 'factory.ts';
export const userFactory = makeFactory<User>({ firstName: 'John', location: { current: 'New York', homeTown: 'Munich', },});
When we want to create a user object, we can use the .build
method:
const user = userFactory.build();
expect(user).toEqual<User>({ firstName: 'John', location: { current: 'New York', homeTown: 'Munich', },});
To override an attribute, we can pass in a DeepPartial<User>
with the attributes we want to override to the .build
method.
const userWithMelbourne = userFactory.build({ location: { homeTown: 'Melbourne', },});
expect(userWithMelbourne).toEqual<User>({ firstName: 'John', location: { current: 'New York', homeTown: 'Melbourne', },});
Sometimes, we want to ensure that our tests are isolated by providing completely different data. A combination of factory.ts
and @faker-js/faker can be excellent for generating large amounts of fake data, while also allowing us to easily override the attributes we need for a test to pass/fail.
import { faker } from '@faker-js/faker';import { makeFactory } from 'factory.ts';import type { User } from './types';
export const userFactoryWithFaker = makeFactory<User>(() => ({ firstName: faker.person.firstName(), location: { current: faker.location.city(), homeTown: faker.location.city(), },}));
You can find examples of using factory.ts
here.