Week 13
Testing with Jest

# Testing with Jest

It is important to test our code, but it can get very tedious, and time consuming to manually test every possible scenario that our application can handle

Luckily, we can write tests to automaically test our code for us! We can run a simple command that will test our entire codebase, and let us know if anything isn't working as it should be.

We will be using this great library Jest (opens new window) to write our test. We can install it as a dev dependency with:

yarn add -D jest

This should be a dev dependency, since tests dont need to be shipped with the production build

We will next add a script to our package.json file.

    ...
    "dev": "nodemon src/index.js",
    "start": "node src/index.js",
    // add the following line
    "test": "jest"

Now, when we run yarn test, jest will look for all files with the extension .test.js, and run the tests in those files!

# Matchers

The most basic way to use jest is to run a function, then run jest's matcher methods against the result to see if it returns the expected result. Take this example of a function that adds to numbers together:

function add(x, y) {
    return x + y;
}

We can test this function with jest by calling the function, and comparing it to the expected result using the following syntax:

test("our first test", () => {
  const result = add(1, 2);
  expect(result).toBe(3);
});

Great! We've written our first test

There are a few more matchers that you should be familiar with, that will help you write your tests:

# toEqual

The toBe matcher only works for basic types likes strings and nubmers, not for objects or arrays. Luckily, we can use toEqual instead to compare objects!

function addToObject(obj1, obj2) {
  return { ...obj2, ...obj1 };
}

test("another test", () => {
  const result = addToObject({ one: "1" }, { two: "2" });
  expect(result).toBe({ one: "1", two: "2" });
});

# toMatchObject

Sometimes, we might not know every attribute that will be returned (think mongo ObjectId). We can use toMatchObject to compare only the attributes that we care about easily.

function mockMongoCreate(input) {
  return { _id: Math.PI, ...input };
}

test("another test", () => {
  const result = mockMongoCreate({ one: "1" });
  expect(result).toMatchObject({ one: "1" });
});

# Unit Tests

Unit Tests are small tests that test only the functionallity of one method of our code. If our function uses another part of our code, or an external library, we will replace those methods with fake data (or mocks), so we can focus solely on the method at hand.

We will create unit tests to test all of the methods in our StudentService. Lets begin by creating students.test.js. We can add identifiers to the output of our test using the describe function.

// src/services/students.test.js

describe('StudentService', () => {

})

# Mock Functions

Jest has a simple method to mock any module / library import in our test. For our StudentService Unit tests, we will want to mock the Student model, because we don't want to actually read or write from mongodb for our tests.

// src/services/students.test.js

jest.mock('../models/student');

The student model has now been mocked, and now has been replaced with a completely new jest Object. We can tell it what to return with depending on the test, and it will keep track of details such as how many times the function has been called, and what the inputs were.


Lets write our first unit test for the getAll method. We will use the describe method again, along with the it method to tell us exactly what each test is testing for.

// src/services/students.test.js

describe('StudentService', () => {
    describe('getAll', () => {
        it('should call Student.find and return the result', 
        async () => {
        })
    })
})

Every test can be broken down into three parts: Arrange , Act, and Assert

# Arrange

In this step, we will set up any mock funcitons, declare inputs, or other data that will be used throughout the test.

// src/services/students.test.js

// Arrange
const EXPECTED = [{
    _id: '123', 
    name: 'tim', 
    grade: 'A+'
}]
// here we tell our mocked Student model what to return
Student.find.mockResolvedValue(EXPECTED);

# Act

In this step, we call the method we are testing

// src/services/students.test.js

// Act
const result = await StudentService.getAll();

# Assert

In this step, we use the jest matcher methods to assert that the correct behaviour / result has happened.

// src/services/students.test.js

// Assert
expect(Student.find.mock.calls.length).toBe(1);
expect(result).toEqual(EXPECTED);

Our first test is complete! All together it should look like this:

describe("#getAll", () => {
    it("'should call Student.find and return the result'", async () => {
        // Arrange
        const EXPECTED = [{
            _id: '123', 
            name: 'tim', 
            grade: 'A+'
        }]
        Student.find.mockResolvedValue(EXPECTED);

      // Act
      const res = await StudentService.getAll();

      //Assert
      expect(Student.find.mock.calls.length).toBe(1);
      expect(res).toEqual([]);
    });
  });

Way to go! Now lets try writing a unit test for the getOne method. Notice this method may throw an error, and has an input, so we will have to test all different scenarios.

describe('getOne', () => {
    it('should call Student.findById', async () => {
        // Arrange
        // Act
        // Assert
    })
    it('should throw an error if not found', async () => {
        // Arrange
        // Act
        // Assert
    })
})

Scenario 1 of our method, we will expect the Student.findById method to be called with the correct id, called exactly once, and to return the result from that method. We can test it with:

// Arrange
const _id = new ObjectId()
const id = _id.toString()
const EXPECTED = {
    _id,
    name: "Tim",
    grade: "A+",
};
Student.findById.mockResolvedValue(EXPECTED);

// Act
const res = await StudentService.getOne(id);

//Assert
expect(Student.findById).toBeCalledWith(id);
expect(Student.findById.mock.calls.length).toBe(1);
expect(res).toEqual(EXPECTED);

Scenario 2 will be when the error is thrown. We can get to this path by mocking the Student.findById to respond with null. In this case, we will expect our error to throw an error, and we can test for errors like so:

// Arrange
const id = new ObjectId().toString();
Student.findById.mockResolvedValue(null);

// Act
//Assert
await expect(StudentService.getOne(id)).rejects.toThrow(
    new NotFoundError(`Student with id ${id} not found`)
);

Great! we now have the basics down for Unit tests. We will complete the rest of the tests for the Student Service together in class.

# Integration Tests

Now that we have tested each individual piece of our application, we need to test that it is all connected properly. This is where the integration test comes in. We send a request just as a user would, and we make sure it goes all the way through our app and back to the user with the expected result. To help us with this, we will add another library called supertest.

yarn add -D supertest

Lets create a new file and folder test/integration/student.test.js so we can keep our tests separate. We can tell jest to only test a certain directory to separate our unit tests from our integration tests. I like to keep the unit tests right next to the file they are testing, and integration tests in their own folder. Lets update our script:

// package.json
...
"test:unit": "jest src",
"test:integration": "jest test/integration",
"test": "yarn test:unit && yarn test:integration"

Integration tests take a lot more setup, so lets get started. First, we need to set up a copy of our express app.

'use strict'

require("dotenv/config");
const express = require("express");
const mongoose = require("mongoose");

const sanitizeBody = require("../../src/middleware/sanitizeBody");
const studentRouter = require("../../src/router/students");
const { errorHandler } = require("../../src/utils/errors");

const app = express();
app.use(express.json());
app.use("/api/students", sanitizeBody, studentRouter);
app.use(errorHandler);

Notice our stripped down version of our app, with just the necessities.

Next, we will need to connect to our database. Jest has some great helper functions to perform actions before or after tests.

if (!process.env.MONGO_URL_TEST?.includes("localhost")) {
  throw new Error("Invalid testing url");
}

// this will be run once at the very start, before any tests begin
beforeAll(async () => {
  await mongoose.connect(process.env.MONGO_URL_TEST);
});

// this will be run multiple times, once before each test
// beforeEach()

// this will be run multiple times, once after each test
afterEach(async () => {
  await Student.deleteMany({});
});

// this will be run once, after all the tests are finished
afterAll(async () => {
  await mongoose.disconnect();
});

It is a good idea to make sure we aren't connected to our remote database, as we will be deleting everything after each test!!

Lets write our first test! We will start with the GET / request.

const request = require('supertest');

describe("Student Resource", () => {
  describe("GET /", () => {
    it("happy path", async () => {
        // Arrange
        // Act
        // Assert
    });
  });
});

We will need to create multiple students throughout our integration tests, so it will be handy to have a helper function that we can reuse. Lets create that now!

// test/integration/mocks/student.js
"use strict";

const Student = require("../../../src/models/student");

const createStudent = async (studentData) => {
  const newStudent = new Student({
    name: "test",
    grade: "A+",
    ...studentData,
  });
  await newStudent.save();

  // this will recreate the behaviour of sending the data through HTTP
  // it will make asserting the data easier later (especially with dates)
  return JSON.parse(JSON.stringify(newStudent));
};

module.exports = {
  createStudent,
};

We can use this helper function to create a user before we make our request, and we should then expect to get that user back in an array from our application.

// Arrange
const createdStudent = await createStudent();

// Act
// supertest has it's own expect method, we can tell it the expected status code
const { body } = await request(app).get("/api/students").expect(200);

// Assert
expect(body).toEqual({
    data: [createdStudent],
});

Our first integration test in the books! Lets test GET /:id next. Remember, it will take in more information, and may return an error!

describe("GET /:id", () => {
    it("happy path", async () => {
      // Arrange
      const createdStudent = await createStudent();

      // Act
      const { body } = await request(app).get(
        `/api/students/${createdStudent._id}`
      );

      // Assert
      expect(body).toEqual({
        data: createdStudent,
      });
    });
    it("should return status 404 for bad request", async () => {
      // Arrange
      const id = new ObjectId().toString();

      // Act
      const { body } = await request(app)
        .get(`/api/students/${id}`)
        .expect(404);

      // Assert
      expect(body).toEqual({
        error: `Student with id ${id} not found`,
      });
    });
  });

Supertest can also run request with data. Here is how we might test for the POST / route:

describe("POST /", () => {
    it("happy path", async () => {
      // Arrange
      const input = {
        name: "tim",
        grade: "A+",
      };

      // Act
      const { body } = await request(app)
        .post("/api/students/")
        .send(input)
        .expect(201);

      const students = await Student.find();
      const student = JSON.parse(JSON.stringify(students[0]));

      // Assert
      expect(body.data).toMatchObject(input);
      expect(student).toMatchObject(input);
    });
    it("should return status 400 for bad request", async () => {
      // Arrange
      const input = { name: "tim" };

      // Act
      const { body } = await request(app)
        .post("/api/students")
        .send(input)
        .expect(400);

      // Assert
      expect(body).toEqual({
        error: "student validation failed: grade: Path `grade` is required.",
      });
    });
  });

Way to go! We will complete the rest of the integration tests together in class.

# Excerise

Testing with tim. Coming soon!

Last Updated: 4/3/2023, 12:04:36 AM