Unit Testing with Jest: Why and How You Should be Testing (part 1)

Unit Testing with Jest: Why and How You Should be Testing (part 1)

Testing is a crucial part of programming. In this introductory crash course to Jest, we'll see how and why you should always be testing your code

ยท

7 min read

Unit testing is a strategy in software development where individual pieces of code are logically isolated and tested. Why exactly? To ensure that each part of the program performs as expected. Units can range from objects and modules to methods or individual functions. The purpose of unit testing is to verify their output.

"But do I really need to be writing tests for my code?" Absolutely. There are many reasons why testing should be a more fundamental part of learning programing, but in my opinion the best reason is that it allows the developer to understand their applications more holistically. It allows them to see integration points and better predict errors and other issues that might not be so obvious.

One program that makes unit testing easier is Jest. Jest is "a delightful JavaScript Testing Framework with a focus on simplicity." Jest is extremely versatile and can be used for testing in React, Babel, Vue, Angular, Typescript and much more. It's an extremely well-documented framework and provides a comprehensive testing toolkit all in one place.

Let's jump right into installing Jest and writing some actual tests! (This crash course will focus primarily on synchronous testing in Jest. It won't be the most practical when it comes to the actual code we test, however it will show you how to set up Jest and how to use several of the matchers. For asynchronous testing and a slightly deeper dive, stick around for part 2!)

After you've followed the instructions from the Jest documentation on getting started, we can create two new files:

  1. sum.js
  2. sum.test.js

We keep the exact same name of the file we'd like to test plus a .test.js. Because we've already installed Jest, it's going to look for the file with this exact structure and it's going to execute the test inside that file.

Now let's fill in sum.js with some complex lines of code.

//sum.js
function sum(num1, num2) {
    return num1 + num2
}

module.exports = sum

Because we installed Jest, we already have access to the entire toolkit. Tests have to exists within a test block. A text block can be specified in the following way.

// sum.test.js
it("name of the test", () => {
      // the logic of our test will go here
});

This test block starts with the it keyword and takes two parameters (1. a name or description of the test and 2. a callback with the logic of the test inside). Let's modify to test our sum.js file.

// sum.test.js
it("should add 5 + 5 to equal 10", () => {
     const value = sum(5,5)
     expect(value).toBe(10)
});

After running the test with npm run test, we get the following output:

Screen Shot 2022-04-09 at 4.58.16 PM.png

Let's break down the line that reads expect(value).toBe(10) in sum.test.js.

expect is another keyword in Jest in which we pass the element we want to examine (in our case, we want to check the value of the function sum when given 5 and 5). Additionally, it gives us access to the many 'matchers' that allow you to test different things. the .toBe(10) following expect is one of those matchers. In this case, toBe() can be used to compare "primitive values or to check referential identity of object instances." For our case, it checks whether the primitive value of sum(5,5) is the same as 10. Since it is, it passes the test.

Let's try something else:

it("object assignment", () => {
    const one_obj = {};
    const two_obj = {};
    expect(one_obj).toBe(two_obj)
})

Here, we are checking to see if an empty object {} will pass a test when used with the .toBe() matcher against another empty object {}.

Screen Shot 2022-04-09 at 5.18.21 PM.png

If you pay attention to the language in the documentation, there are two word that should tell you why an empty object will not pass the above test when compared to another empty object: primitive value. I've already written about the difference between primitive values and object references before, but in essence, .toBe({}) is comparing if the value of {} and {} are the same. Since they are objects stored in-memory, they are not. (Read this essay for further explanation). However, luckily Jest has a plethora of matchers we can use instead.

it("object assignment", () => {
    const one_obj = {};
    const two_obj = {};
    expect(one_obj).toEqual(two_obj)
})

Screen Shot 2022-04-09 at 5.28.42 PM.png

The only modification made is that we used toEqual({}) rather than toBe({}). toEqual compares "recursively all properties of object instances (also known as "deep" equality). It calls Object.is to compare primitive values, which is even better for testing than === strict equality operator."

What if we want to run multiple tests?

We can use describe to break our tests into different components and group related tests together. This isn't required however. Tests can be written by themselves at the top level. But it could make it easier if the organization and grouping of your code was matched by the organization and grouping of your tests.

Similar to the it block a describe block is made like so:

describe("example tests", () => {
    it("should add 1 + 2 to equal 3", () => {
        const result = sum(1,2);
        expect(result).toBe(3)
    })
    it("object assignment", () => {
        const one_obj = {};
        const two_obj = {};
        expect(one_obj).toEqual(two_obj)
    })
})

Screen Shot 2022-04-09 at 5.46.47 PM.png

Here, we have one test suite named "example tests" with two tests inside.

Can we have more than one expect for a test?

Yes, BUT this is not advised. For the sake of exploring more matchers, let's explore the following code and learn quickly why it's not advised to have a series of expects.

describe("truthy or falsy", () => {
      it("null", () => {
          const n = null;
          expect(n).toBeFalsy()
          expect(n).not.toBeTruthy() 
          expect(n).toBeNull()
          expect(n).not.toBeUndefined()
        })
    })

Here, we have a suite named "truthy or falsy" with a single test called "null". As the name suggests, we are running a series of matchers within that test that are testing whether null in each scenario will return true or false. (In JavaScript, null evaluates to falsy.)

Thankfully, the name of the matchers are descriptive enough to guess correctly what they do. toBeFalsy() checks if the expect argument is false, toBeTruthy() checks that it's true, and so on. The one other keyword we haven't gone over yet is not which allows you to test the opposite.

Screen Shot 2022-04-10 at 11.01.16 AM.png

In the current setup, the test passes because all the excepts return true. But what happens if one of them is false?

describe("truthy or falsy", () => {
      it("null", () => {
          const n = null;
          expect(n).toBeTruthy()
          expect(n).not.toBeTruthy() 
          expect(n).toBeNull()
          expect(n).not.toBeUndefined()
        })
    })

Screen Shot 2022-04-10 at 11.04.37 AM.png

Since the first except statement is false, the entire test returns false. Although this may be something you want to do depending on your code's architecture, it can become difficult to pinpoint why a test is failing as your program grows and becomes more complex.

What other matchers are there?

Quite a few! Not all of them were mentioned here but here's the exhaustive list:

  • expect(value)
  • expect.extend(matchers)
  • expect.anything()
  • expect.any(constructor)
  • expect.arrayContaining(array)
  • expect.assertions(number)
  • expect.closeTo(number, numDigits?)
  • expect.hasAssertions()
  • expect.not.arrayContaining(array)
  • expect.not.objectContaining(object)
  • expect.not.stringContaining(string)
  • expect.not.stringMatching(string | regexp)
  • expect.objectContaining(object)
  • expect.stringContaining(string)
  • expect.stringMatching(string | regexp)
  • expect.addSnapshotSerializer(serializer)
  • .not
  • .resolves
  • .rejects
  • .toBe(value)
  • .toHaveBeenCalled()
  • .toHaveBeenCalledTimes(number)
  • .toHaveBeenCalledWith(arg1, arg2, ...)
  • .toHaveBeenLastCalledWith(arg1, arg2, ...)
  • .toHaveBeenNthCalledWith(nthCall, arg1, arg2, ....)
  • .toHaveReturned()
  • .toHaveReturnedTimes(number)
  • .toHaveReturnedWith(value)
  • .toHaveLastReturnedWith(value)
  • .toHaveNthReturnedWith(nthCall, value)
  • .toHaveLength(number)
  • .toHaveProperty(keyPath, value?)
  • .toBeCloseTo(number, numDigits?)
  • .toBeDefined()
  • .toBeFalsy()
  • .toBeGreaterThan(number | bigint)
  • .toBeGreaterThanOrEqual(number | bigint)
  • .toBeLessThan(number | bigint)
  • .toBeLessThanOrEqual(number | bigint)
  • .toBeInstanceOf(Class)
  • .toBeNull()
  • .toBeTruthy()
  • .toBeUndefined()
  • .toBeNaN()
  • .toContain(item)
  • .toContainEqual(item)
  • .toEqual(value)
  • .toMatch(regexp | string)
  • .toMatchObject(object)
  • .toMatchSnapshot(propertyMatchers?, hint?)
  • .toMatchInlineSnapshot(propertyMatchers?, inlineSnapshot)
  • .toStrictEqual(value)
  • .toThrow(error?)
  • .toThrowErrorMatchingSnapshot(hint?)
  • .toThrowErrorMatchingInlineSnapshot(inlineSnapshot)

And if you're curious to learn more read the documentation and give it a try on your latest project!

ย