Regardless of which language you use, you need test data when writing tests. Here are some tips on how to make it easier for yourself, with examples in TypeScript.
What's the problem with test data?
Writing good tests isn't easy. A critical aspect is being able to understand what the test is about. This is important when the test fails and you're wondering why.
A major source of confusion is the dataset required to run the test code. How do you know what's important for the test, and what's just filler to satisfy the interface requirements?
An example
Let's say we have a user.ts
with the following code that we want to test:
const usersByEmail: Record<string, IUser> = {};
export const addUser = (user: IUser) => {
if (usersByEmail[user.email]) throw new Error("Email must be unique")
usersByEmail[user.email] = user;
}
export const getUserByEmail = (email: string) => {
return usersByEmail[email];
}
An IUser
has an IAddress
and a list of IPermission
.
We write a test to check that addUser
updates usersByEmail
.
test("can add a user", () => {
// Fill out everything manually, hard to see what's essential for the test
const newUser: IUser = {
email: "test@example.com",
name: "Testing",
age: 10,
address: { country: "no", zip: 123, street: "Main street 45" },
created: new Date(),
permissions: [{ id: "somePermission", description: "Don't really care in this test" }]
};
addUser(newUser)
const result = getUserByEmail("test@example.com");
expect(newUser).toEqual(result);
})
The test creates a new user and verifies that we can retrieve the same user again. The essential property of the user is the email. The rest is just noise. Unfortunately, we need to include them to satisfy the type checker.
A hack
We can try a trick to fool the compiler:
test("a trick", () => {
// Cast to IUser
const newUser = { email: "fake@example.com" } as IUser;
addUser(newUser);
const result = getUserByEmail("fake@example.com");
expect(newUser).toEqual(result);
})
This became more readable. Here it's clear that only the user's email is important. However, casting can quickly lead to us fooling ourselves. When we're already using TypeScript and have taken the trouble to define types, we want to get as much as possible out of that investment.
What can we do?
We create some helper functions to create users. user
returns a valid IUser
, with default values for each field. The function takes a Partial<IUser>
as an argument, which is spread on the return value. This allows us to override whatever we want from our tests.
export const permission = (overrides: Partial<IPermission>): IPermission => {
return {
id: "defaultPermission",
description: "A default permission",
...overrides
}
}
export const address = (overrides: Partial<IAddress>): IAddress => {
return {
zip: 123,
street: "Default street 1",
country: "no",
...overrides
}
}
export const user = (overrides: Partial<IUser>): IUser => {
return {
name: "Default name",
age: 1,
address: address({}),
created: new Date(2020, 10, 8),
email: "default@default.com",
permissions: [permission({})],
...overrides
}
}
If we want to create a user with an address in Sweden, we can do it like this:
user({ email: "sven@sverige.se", address: address({ country: "se" }) })
We can now write our test like this:
test("can create user with a helper function", () => {
const newUser = user({ email: "help@example.com" })
addUser(newUser);
const result = getUserByEmail("help@example.com");
expect(newUser).toEqual(result);
})
Readable without tricks! The more complicated your domain is, the more useful these composable helper functions become. A bonus is that they make it easier to develop the code further. When it changes, you can adapt the helper functions in one place, instead of having to update all the tests when a user gets a new field.
Named variables
In addition to the helper functions above, you can also create some named test data variables. These can be variants of test data that are semantically meaningful. You can use the helper functions to define these. For example, it could be an adminUser
or addressInTimezoneWithDaylightSaving
.
In the example below, we use two named permissions
.
test("can use named test data", () => {
const newUser = user({ email: "bad@example.com", permissions: [permissionToRead, permissionToWrite] })
addUser(newUser);
banUser("bad@example.com");
const result = getUserByEmail("bad@example.com");
expect(result.permissions).toEqual([])
})
A warning
A potential pitfall with shared test data functions is that they can create hidden dependencies between tests. You need to think carefully when defining default values. It's not necessarily easy to understand what's okay to change without breaking some tests. In the worst case, a test data change can cause tests to no longer verify what you wanted, but still run green.
For those who are extra interested, the source code for the examples above is available here