Writing test cases or unit tests is a tedious task. They are usually long lines of functions calls and assertions of the expected and received results. Fortunately, test frameworks like Jest make it quite easy and intuitive to test your application.
Jest already provides plenty of Matchers out of the box. These are the methods that you call on expect(value)
like toBe()
, toEqual()
or toMatch()
. However, sometimes you might find yourself in a situation where you need to test multiple test cases but expect the same or a similar result. For example, you need to test your GraphQL or REST API to create, read, and update an object, e.g. a Todo
. Each API returns a Todo
object with certain properties like ID, title, etc. In this situation we could write our own custom Matcher toMatchTodo()
that we can reuse in various test cases when we expect a Todo object or even an array of Todo objects.
Test Case
Let's start with the actual test case before we go into the implementation. This should make it clearer what we are trying to achieve. Let's say we are writing a test case for a Todo API and want to test the getTodo
, listTodo
, and createTodo
endpoints. We're using the JSON Placeholder API and specifically the /todos
resource.
describe('Todo API', () => {
test('Get Todo By ID', async () => {
const todo = await fetch(`https://jsonplaceholder.typicode.com/todos/1`).then((r) => r.json());
// match any Todo item
expect(todo).toMatchTodo();
// match specific Todo item
expect(todo).toMatchTodo({
id: 1,
userId: 1,
title: 'delectus aut autem',
completed: false,
});
});
test('List all Todos ', async () => {
const todos = await fetch(`https://jsonplaceholder.typicode.com/todos`).then((r) => r.json());
// match any array of Todos
expect(todos).toMatchTodo([]);
// match array of Todos with specific Todos
expect(todos).toMatchTodo([
{
id: 1,
userId: 1,
title: 'delectus aut autem',
completed: false,
},
{
id: 2,
userId: 1,
title: 'quis ut nam facilis et officia qui',
completed: false,
},
]);
});
test('Create Todo', async () => {
const newTodo = {
userId: 1,
title: 'quis ut nam facilis et officia qui',
completed: false,
};
const todo = await fetch(`https://jsonplaceholder.typicode.com/todos`, {
method: 'POST',
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
body: JSON.stringify(newTodo),
}).then((r) => r.json());
// match any Todo item
expect(todo).toMatchTodo();
// match specific newTodo item, but match any ID property as it's generated by the server
expect(todo).toMatchTodo(newTodo);
});
});
In each test()
block we are dealing with two possible options. If we expect the returned object to be any Todo, but we don't know the actual property values, we can at least verify the object has these properties:
// match any Todo item
expect(todo).toMatchTodo()
// or, match any array of Todo items
expect(todos).toMatchTodo([]);
However, if we expect the returned object to be a specific Todo, then we must verify it has exactly these properties:
// match specific Todo item
expect(todo).toMatchTodo({
id: 1,
userId: 1,
title: 'delectus aut autem',
completed: false,
});
// or, match array of Todos with specific items
expect(todos).toMatchTodo([
{
id: 1,
userId: 1,
title: 'delectus aut autem',
completed: false,
},
{
id: 2,
userId: 1,
title: 'quis ut nam facilis et officia qui',
completed: false,
},
]);
The second option is useful when creating a new item on the server and it responds with the new item. In such a case, we're partially matching the returned object because we know some properties but others are generated by the server, for example the ID or creation date.
Custom Matcher toMatchTodo()
Jest allows us to add your own matchers via its expect.extend method. The actual implementation uses expect.objectContaining and expect.arrayContaining to define the expected result and this.equals(received, expected)
to perform the equality check.
expect.extend({
toMatchTodo(received, expected) {
// define Todo object structure with objectContaining
const expectTodoObject = (todo?: Todo) =>
expect.objectContaining({
id: todo?.id || expect.any(Number),
userId: todo?.userId || expect.any(Number),
title: todo?.title || expect.any(String),
completed: todo?.completed || expect.any(Boolean),
});
// define Todo array with arrayContaining and re-use expectTodoObject
const expectTodoArray = (todos: Array<Todo>) =>
todos.length === 0
? // in case an empty array is passed
expect.arrayContaining([expectTodoObject()])
: // in case an array of Todos is passed
expect.arrayContaining(todos.map(expectTodoObject));
// expected can either be an array or an object
const expectedResult = Array.isArray(expected)
? expectTodoArray(expected)
: expectTodoObject(expected);
// equality check for received todo and expected todo
const pass = this.equals(received, expectedResult);
if (pass) {
return {
message: () =>
`Expected: ${this.utils.printExpected(expectedResult)}\nReceived: ${this.utils.printReceived(received)}`,
pass: true,
};
}
return {
message: () =>
`Expected: ${this.utils.printExpected(expectedResult)}\nReceived: ${this.utils.printReceived(
received,
)}\n\n${this.utils.diff(expectedResult, received)}`,
pass: false,
};
},
});
First, we define our custom matcher toMatchTodo(received, expected)
with two arguments. The first argument received
is the value we have passed to expect(value)
and the second argument expected
is the value we have passed to toMatchTodo(value)
.
The following expectTodoObject
function defines the Todo object properties we expect to receive and which value they should have. The value can match strictly, that means it must be equal to the given value, or when we don't know the value we can expect any value of a given type, for example expect.any(Number)
. The second expectTodoArray
function handles the case when we expect an array of Todos. In this case we must distinguish between expecting an array of any Todos and expecting an array of specific Todos. We achieve that by checking the length of the passed array to the matcher, for example to expect an array of any Todos: expect(todos).toMatchTodo([])
.
Finally, we apply the previous two functions according to the given expected
value. If it's an array (empty or non-empty) we apply expectTodoArray
, otherwise expectTodoObject
. This gives us an expectedResult
object that encapsulates our whole expected structure and is used for the actual equality check with Jest's this.equals(received, expected)
and to print the diff of received and expected to the console.
Test Results
In case you wonder what happens if the test cases actually fail, so I added faulty test statements to each test case. I thought about the following issues that might actually go wrong:
-
getTodo
: the API didn't return all the properties of an item -
listTodos
: the API didn't return the expected two items -
createTodo
: the API didn't return the item ID as number
The following sandbox shows the failed test results with formatted output of expected and received values. This output is generated by our own toMatchTodo
function.
Enable TypeScript Types
If you are using Jest with TypeScript as I usually do, you can add type definitions for your custom matcher. These will then be available on the expect(value)
function.
type Todo = {
id: number;
userId: number;
title: string;
completed: boolean;
};
interface CustomMatchers<R = unknown> {
toMatchTodo(todo?: Partial<Todo> | Array<Partial<Todo>> | undefined): R;
}
declare global {
namespace jest {
interface Expect extends CustomMatchers {}
interface Matchers<R> extends CustomMatchers<R> {}
interface InverseAsymmetricMatchers extends CustomMatchers {}
}
}
Full Test Case
I want to save you from manually copying the snippets one by one, so here is a Gist with the complete test file. This can be easily executed with Jest (or ts-jest for TypeScript).
Top comments (3)
Chris, this is very helpful. I don't want to define my models (
type Todo
) in the same file as my jest global settings. Also, I have many test files where I need the same custom matcher. Can you describe the files used in a professional repo? What file containsdeclare global
?same question, any ideas @zirkelc ?
Hi @angelxmoreno and @cyrfer
sorry for never responding on this topic, I seem to have forgotten about this notification. Nevertheless, I have since switched from Jest to Vitest, but the general process should be the same.
I have all my custom matchers in separate package in my monorepo, it's called
lib-test
and I install it in every other package that needs some test utilities like matchers or snapshot serializer. Thematcher.ts
file contains all custom matchers and defines theCustomMatchers
interface. Here, I also add the interface to the Jest namespace (declare global { ...}
) so it's available on theexpect()
functions.One thing to note is, I don't use
setupFilesAfterEnv
in Jest config to automatically add the custom matchers. Instead, only in the test cases where I need the custom matcher, I import thetoMatchTodo()
from mylib-test
and callexpect.extend({ toMatchTodo })
at the beginning.Because of this, the custom matcher is always available on the Jest namespace because in every test file that uses the custom matcher, the import from
lib-test
ensures the Jest namespace (declare global { ...}
) was extended.I hope this helps!