How To Implement Custom Matchers
Tags: javascript, testing, typescript, react
Published: 2022-05-18 13:16:29

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](https://jestjs.io/) make it quite easy and intuitive to test your application.

Jest already provides plenty of [Matchers](https://jestjs.io/docs/26.x/expect) 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](https://jsonplaceholder.typicode.com/) and specifically the [`/todos`](https://jsonplaceholder.typicode.com/todos) resource.

```ts
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:

```ts
// 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:

```ts
// 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](https://jestjs.io/docs/26.x/expect#expectextendmatchers) method. The actual implementation uses  [expect.objectContaining](https://jestjs.io/docs/26.x/expect#expectobjectcontainingobject) and [expect.arrayContaining](https://jestjs.io/docs/26.x/expect#expectarraycontainingarray) to define the expected result and `this.equals(received, expected)` to perform the equality check.

```ts
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. 
{% embed 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.

```typescript
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](https://gist.github.com/zirkelc/1ebb4e3d026bb85b6cbd4ae21174f83c) with the complete test file. This can be easily executed with Jest (or [ts-jest](https://kulshekhar.github.io/ts-jest/) for TypeScript).