Custom Jest Matchers

This is my first post, I would gladly get feedback and other opinions about it.

Jest is a great testing library that can bring stability to your codebase. On top of having fewer bugs in your codebase, I figured out the tests also give a human-readable brief of what the code should do.

To improve on the latter point, custom matchers can be a great help. Jest matchers bring great readability to the test suit and creating your own matchers can even improve it. Let's say we have a Sudoku game and we want to test that each number is unique in the set. We would need to write a series of functions that will get the number assigned to the square, then all the numbers in the row and then check that the tested number is unique in that row. Porting all that code to a custom matcher would save us a headache and would help us make our test much easier to understand. Reading something like that expect(rowElement).toHaveUniqueNumbersInRow() is much more straightforward.

The Boilerplate

toCustomlyMatch will be a custom jest matcher.

// src/tests-util/custom-matchers/toCustomlyMatch.ts
import { expect } from '@jest/globals'

expect.extend({
  toCustomlyMatch(
    received: unknown,
    expected: unknown
  ) {
    return {
      pass: received === expected,
      message: () => 'test failed',
    }
  },
})

interface CustomMatchers<R = unknown> {
  toCustomlyMatch: (expected: unknown) => R
}

declare global {
  namespace jest {
    interface Expect extends CustomMatchers { }
    interface Matchers<R> extends CustomMatchers<R> { }
    interface InverseAsymmetricMatchers extends CustomMatchers { }
  }
}

Now to use this matcher we need to import it into our setupTests.ts file.

// src/setupTests.ts
import 'tests-util/custom-matchers/toCustomlyMatch'

Now when writing a test case, you should see toCustomlyMatch as one of the matchers on expect. expect().toCustomlyMatch().

Pay attention to the return object of the function. It expected two properties: pass, message. If pass is true, the test passed. Else the message will run and give the developer an indication of what went wrong.

Sudoku Example

Let's go back to the Sudoku example. We will write a custom matcher for checking if the row has unique numbers. Along side Jest, we will use @testing-library/react.

Assuming we have such a React component:

// src/components/SudokuRow.tsx
interface Props {
  numbers: string[]
}

const SudokuRow = ({ numbers }: Props): JSX.Element => (
  <>
    {
      numbers.map((number: string) => (
        <div key={number}>{number}</div>
      ))
    }
  </>
)

export default SudokuRow

The corresponding test file will be:

// src/components/SudokuRow.test.tsx
import { render, screen } from '@testing-library/react'
import SudokuRow from 'SudokuRow'

describe('App', () => {
  test('renders SudokuRow', () => {
    render(<SudokuRow numbers={['1', '7', '3', '5', '8', '4', '9', '6', '2']} />)

    const numberElem = screen.getByText('1')
    expect(numberElem).toBeInTheDocument()
  })
  test('row has only unique numbers', () => {
    render(<SudokuRow numbers={['1', '7', '3', '5', '8', '4', '9', '6', '2']} />)

    const numberElem = screen.getByText('1')
    const rowContainer = numberElem.parentElement
    Array.from(rowContainer?.children ?? []).forEach((checkElem) => {
      const checkForNumber = checkElem.innerHTML
      const allNumbers = Array.from(rowContainer?.children ?? []).map((elem) => elem.innerHTML)
      expect(allNumbers.filter((num) => num === checkForNumber).length).toBe(1)
    })
  })
})

The first test is just a basic one to see if the component renders properly. The second test row has only unique numbers is the one we will focus on. We can clearly see the test is doing the job, it will fail if we have duplicated number in the numbers-array. But on the other hand it holds some complex logic which prevents us from reading the test clearly and also prevents us from reusing this logic in other tests. Jets custom matchers to the rescue. We will write the custom-matcher toHaveUniqueNumbersInRow to achieve readability and re-usability.

Using the above boilerplate, lets setup the matcher

// src/tests-util/custom-matchers/toHaveUniqueNumbersInRow.ts
import { expect } from '@jest/globals'

expect.extend({
  toHaveUniqueNumbersInRow(
    received: unknown,
    expected: unknown
  ) {
    return {
      pass: received === expected,
      message: () => 'test failed',
    }
  },
})

interface CustomMatchers<R = unknown> {
  toHaveUniqueNumbersInRow: (expected: unknown) => R
}

declare global {
  namespace jest {
    interface Expect extends CustomMatchers { }
    interface Matchers<R> extends CustomMatchers<R> { }
    interface InverseAsymmetricMatchers extends CustomMatchers { }
  }
}

And don't forget to import the matcher in your tests setup:

// src/setupTests.ts
import 'tests-util/custom-matchers/toHaveUniqueNumbersInRow'

Now when we write expect(). in our test we will have toHaveUniqueNumbersInRow as a matcher option on it. Since we are going to check if all numbers in the row are unique we do not need an expected value, means we will write it like this expect(rowElement).toHaveUniqueNumbersInRow(). In our custom-matcher we can omit the expected param and received will be typed as HTMLElement:

// src/tests-util/custom-matchers/toHaveUniqueNumbersInRow.ts
...
expect.extend({
  toHaveUniqueNumbersInRow(
    received: HTMLElement,
    // remove expected here
  ) {
    return {
      pass: received, // remove expected here
      message: () => 'toHaveUniqueNumbersInRow failed',
    }
  },
})

interface CustomMatchers<R = unknown> {
  toHaveUniqueNumbersInRow: () => R // remove expected here
}

...

Now we can port the logic into the function body:

// src/tests-util/custom-matchers/toHaveUniqueNumbersInRow.ts
...
expect.extend({
  toHaveUniqueNumbersInRow(
    received: HTMLElement,
  ) {
    const rowContainer = received
    const childrenArray = Array.from(rowContainer?.children ?? [])
    let pass = true
    const dupNum: string[] = []

    childrenArray.forEach((checkNumElem) => {
      const checkForNumber = checkNumElem.innerHTML
      const allNumbers = childrenArray.map((elem) => elem.innerHTML)
      const numOccurrences = allNumbers.filter((num) => num === checkForNumber)
      if (
        numOccurrences.length > 1
        && !dupNum.includes(checkForNumber)
      ) {
        dupNum.push(checkForNumber)
        pass = false
      }
    })

    return {
      pass,
      message: () => `these numbers: ${dupNum.join(', ')}, are duplicated in the row`,
    }
  },
})
...

The logic is written slightly different than in the original test example but achieves the same goal. Also note we can generate a much more developer friendly message that will inform us of the found issue. That is it, now we can go back to our test and make it much easier to understand:

// src/components/SudokuRow.test.tsx
import { render, screen } from '@testing-library/react'
import SudokuRow from 'SudokuRow'

describe('App', () => {
  test('renders SudokuRow', () => {
    render(<SudokuRow numbers={['1', '7', '3', '5', '8', '4', '9', '6', '2']} />)

    const numberElem = screen.getByText('1')
    expect(numberElem).toBeInTheDocument()
  })
  test('row has only unique numbers', () => {
    render(<SudokuRow numbers={['1', '7', '3', '5', '8', '4', '9', '6', '2']} />)

    const numberElem = screen.getByText('1')
    const rowContainer = numberElem.parentElement
    expect(rowContainer).toHaveUniqueNumbersInRow()
  })
})

And we are done. Next time we come around this file it will take a fraction of the time to read the test suit and understand what it should achieve.

Happy Jesting