Improving testing assertions

05 Jan 2022

Testing with jest is an activity developers do to keep the application maintainable and time-proof. Therefore, learning a testing framework can be a consuming task, as it often has many features to explore. The assertion API (Application Programming Interface) usually is one of the most important, it is the one that the developer uses the most during the TDD (Test Driven Development) flow - regardless of the tech stack, here we are going to focus on javascript.

In general, the gist of the assertion API is to compare values, as such the equals match is the most used (in my experience). On the other hand being one of the most used can also point to a lack of knowledge in the different assertions that the testing framework offers. Sometimes this lack of understanding can lead to common errors such as the environment in which jest executes or the async behaviour.

This post aims to cover different assertions to avoid using always toEqual and make the test case more expressive. For each example, I try first to depict how it would be with toEqual, then I show another way using a different assertion. Besides that, we will dive into timers and how to deal with that in jest. If you prefer video format, have a look at the meetup we hosted to talk about assertions or the slides hosted at speakerdeck.

Assertions 

This section focuses on the assertions that we can use and alternatives to “assertion smells”. To make this point, the post follows an approach comparing the assert.toEqual approach against a more expressive assertion for each scenario. Besides that, each section is categorized as following: primitives 🧱, modifiers 🪣, callbacks 📱, timers ⏰ and async 🏹.

PRIMITIVES  🧱

ANY

Any is a generalization to use when the value of the result is not needed, rather the type is.

const isNumber = number => number

expect(typeof isNumber(2)).toEqual('number')

An alternative to this approach would be to use the any:

const isNumber = number => number

expect(isNumber(2)).toEqual(expect.any(Number))

ARRAY CONTAINING

Thinking about assert.equal, an approach to assert an entry of arrays, would be to go through them and assert each of them, for example:

const expectedFruits = ['banana', 'mango', 'watermelon']

 

expect(expectedFruits[0]).toEqual('banana')

expect(expectedFruits[1]).toEqual('mango')

expect(expectedFruits[0]).toEqual('watermelon')

Therefore another approach to assert such structure is using arrayContaining:

const expectedFruits = ['banana', 'mango', 'watermelon']

const actualFruits = () => ['banana', 'mango', 'watermelon']

expect(expectedFruits).toEqual(expect.arrayContaining(actualFruits))

TO HAVE LENGTH

For checking the size of an array is possible using the length property. There are different ways to achieve that, for example, with assert equals, would be something:

const myList = [1, 2, 3]

expect(myList.length).toEqual(3)

Therefore, jest offers a matcher specifically for that, instead of asserting the length property. The same snippet using toHaveLength would become:

const myList = [1, 2, 3]

expect(myList).toHaveLength(3)

TO BE GREATER THAN

Asserting values greater than other can be achieved with raw assert.equals, such as:

const expected = 10

const actual = 3

expect(expected > actual).toEqual(true)

The disadvantage here is that when reading the assertion it takes a bit more to understand the code in our head, as you need to understand what is going on with the variables expected and actual. For that, jest offers an assertion that is more readable to follow (and also gives a more friendly message when failing).

const expected = 10

const actual = 3

expect(actual).toBeGreaterThan(expected)

Using toBeGreaterThan unloads the extra load you might need to read the two variables before the assertion.


MODIFIERS 🪣

NOT

The not modifier is handy when it comes to assert the negation of a given sentence. For context, a indication that .not is needed would be asserting false in some result, for example:

const isOff = false

expect(!isOff).toBe(true) // <--- this sometimes is tricky to spot, same extra load thinking about the negation of the variable off

Another way to achieve the same result but being explicitly would be something as follows:

const isOff = false

expect(isOff).not.toBe(true)

The .not operator can be used across different assertions within jest, for example, when testing reactjs code, often we see .not.toBeInTheDocument.

CALLBACKS 📱

Callbacks are the heart of javascript and when testing them an async style is used as well, as the callback might/might not be called at a different time in the execution flow.

TO HAVE BEEN CALLED

Asserting that a callback has been invoked can be achieved in different ways, for this purpose the first approach (and not recommend) is to use the async style as in the previous, given the following function:

const callAsyncFunc = (callback) => callback();

Then:

it('callback has been invoked', done => {

  callAsyncFunc(() => {

    expect(true).toEqual(true) <--- assumes it has been called, but it was not, jest thinks the test is passing

  })

})

A more readable assertion would be using toHaveBeenCalled, as it is human readable and might take less time to understand what the test case is asserting jest uses this spy to assert calls against it assert that the function has been called, regardless of the number of calls

it('callback has been invoked', done => {

  const result = jest.fn()

  callAsyncFunc(result)

  expect(result).toHaveBeenCalled()

})

TO HAVE BEEN CALLED TIMES

Asserting that a function has been called is the most basic assertion in this respect. There are variants that are more strict than that. For example, it is possible to assert that a given function has been called X times, as opposed to toHaveBeenCalled that does not match exactly the number of calls.

it('callback has been invoked', done => {

  const result = jest.fn()

  callAsyncFunc(result)

  callAsyncFunc(result)

  callAsyncFunc(result)

  callAsyncFunc(result)

 

  expect(result).toHaveBeenCalledTimes(4)

})

The code above assert that the given spy is called 4 times, any number different than that will fail the test case.

TIMERS ⏰

Usually dealing with timers and anything related to dates is non-deterministic. In a scenario in which you need to wait for something to happen, often, during the testing phase we need to mock this functionality. For example, the following code builts on top of the previous example, but using fake timers.

describe("timers", () => {

  beforeEach(() => {

    jest.useFakeTimers();

  });

  afterEach(() => {

    jest.useRealTimers();

  });

  test("should handle next scene", async () => {

    const value = callAsyncFunction();

    jest.advanceTimersByTime(2000);

    expect(await value).toBe(true);

  });

});

Using fake timers, allow us during the testing phase to move forward in the future without having to actually wait for the time to pass by.

ASYNC 🏹

Jest provides an API for a more readable test code and to assert async functions. It is easy to fall under the trap of using assert equals after a promise has been fulfilled.

RESOLVES

Testing async code comes with challenges and the approach to test also changes. One way to test is to use the variable that comes from the it callback. Given the following code:

function callAsyncFunction() {

  return new Promise((resolve) => {

    setTimeout(() => {

      resolve(true);

    }, 1000);

  });

}

It would be something like:

it('my async test', done => {

  callAsyncFunc().

    then((value) => {

      expect(value).toBe(true)

      done()

    })

})

The code above depicts how to assert a value once the promise resolves. Jest provides a more readable way of doing things with resolves:

it('my async test', async () => {

  await expect(callAsyncFunc()).resolves.toEqual(true)

})

The same applies to a rejected promise, in this case we would change the resolves by rejects.

it('my async test', async () => {

  await expect(callAsyncFunc()).rejects.toEqual(false)

})

WHERE TO GO FROM HERE?

Hopefully, these examples will give you the chance to explore jest as a way to improve your assertions and your test readability. Those details on writing code might be the difference between finding the root cause for a failing test.

If you are not familiar with jest or you use another tech stack, that's fine too, your favourite testing framework will support you with similar functionality.

From here, I would recommend listening to my colleagues talking about "Building a testing culture" for a more deep dive on TDD "The Transformation priority premise" and last but not least, TDD Knowledge Book- Insights from the first TDD Conference.  

As always: happy testing!