DEV Community

Chris Cook
Chris Cook

Posted on • Edited on

How To Implement Custom Matchers

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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([]);
Enter fullscreen mode Exit fullscreen mode

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,
  },
]);
Enter fullscreen mode Exit fullscreen mode

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,
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

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 {}
  }
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
cyrfer profile image
John Grant

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 contains declare global?

Collapse
 
angelxmoreno profile image
Angel S. Moreno

same question, any ideas @zirkelc ?

Collapse
 
zirkelc profile image
Chris Cook

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. The matcher.ts file contains all custom matchers and defines the CustomMatchers interface. Here, I also add the interface to the Jest namespace (declare global { ...}) so it's available on the expect() 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 the toMatchTodo() from my lib-test and call expect.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!