Custom Matchers in Jest
// 3 comments
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 itemlistTodos
: the API didn't return the expected two itemscreateTodo
: 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.
https://codesandbox.io/embed/jest-test-forked-bwhl8d?fontsize=14&hidenavigation=1&module=%2Findex.test.js&previewwindow=tests&theme=dark
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).