{ Writing API Tests with Jest. }

Objectives:

By the end of this chapter, you should be able to:

  • Write code to test API endpoints
  • Use jest and supertest to write unit and integration tests

Jest Introduction

Jest is a wonderful testing library created by Facebook to help test JavaScript code, React components, and much more. What's great about Jest is it not only has a similar syntax to other testing/assertion libraries like Jasmine and Chai, but with Jest your tests run in parallel so they are executed much faster than other testing frameworks.

To use jest globally we can install it with npm install -g jest. If you are getting errors with this installation you can use sudo npm install -g jest. Since installing with sudo is not the best option, you can read more about how to fix permissions here if you would like to install without sudo going forward.

Since we are using testing libraries, these are not dependencies that will be using in production. Therefore, when we install these using npm we want to make sure to run npm install --save-dev rather than npm install, so that we do save them to our package.json, but do not install them in production. When you do this, you should see that your package.json consists of two sets of dependencies: dependencies and devDependencies.

Unit Tests

To start, we're going to use jest to write a few simple unit tests. Let's run the following in Terminal.

mkdir learn-jest
cd learn-jest
npm init -y
mkdir __tests__
touch __tests__/first.test.js
touch index.js

Notice here that we have placed our tests inside of a __tests__ folder, which is important for jest to

Let's now run jest (make sure you have installed it globally using npm install -g jest) and we should see the following:

 FAIL  __tests__/first.test.js
  ● Test suite failed to run

    Your test suite must contain at least one test.

      at node_modules/jest/node_modules/jest-cli/build/test_scheduler.js:246:22

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        1.488s
Ran all test suites.

So let's go add a simple test in our first.test.js file! Inside of there let's write:

test("It adds two numbers", () => {
  expect(1 + 1).toBe(2);
});

When we run jest again, we should see the following:

 PASS  __tests__/first.test.js
   it adds two numbers (4ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.018s
Ran all test suites.

Looks good! Let's now run jest --watchAll and we should see the following:

 PASS  __tests__/first.test.js
   it adds two numbers (4ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.991s, estimated 1s
Ran all test suites.

Watch Usage
  Press f to run only failed tests.
  Press o to only run tests related to changed files.
  Press p to filter by a filename regex pattern.
  Press t to filter by a test name regex pattern.
  Press q to quit watch mode.
  Press Enter to trigger a test run.

What we're looking at here is the ability for Jest to constantly watch for changes to our tests! This is a wonderful way to not have to run jest every time we want to see if our tests pass and makes Test Driven Development even easier!

Let's try testing a simple function in our index.js file. Let's write a function called letterCount which accepts a character and a string and returns the number of times that character appears. We can do this from a TDD perspective so let's write some failing tests first! In our first.test.js file let's add:

const letterCount = require("../"); // same as ../index.js

test("letterCount works with regular strings", () => {
  expect(letterCount("awesome", "e")).toBe(2);
});

Right away we should see:

 FAIL  __tests__/first.test.js
  ✕ letterCount works with regular strings (5ms)

  ● letterCount works with regular strings

    TypeError: letterCount is not a function

      2 |
      3 | test("letterCount works with regular strings", () => {
    > 4 |   expect(letterCount("awesome", "e")).toBe(2);
        |          ^
      5 | });
      6 |

      at Object.<anonymous>.test (__tests__/first.test.js:4:10)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.475s, estimated 1s
Ran all test suites.

In our index.js let's go make sure we have a function!

function letterCount(str, char) {}

module.exports = letterCount;

Back in Terminal - we should now see the following output:

 FAIL  __tests__/first.test.js
  ✕ letterCount works with regular strings (5ms)

  ● letterCount works with regular strings

    expect(received).toBe(expected) // Object.is equality

    Expected: 2
    Received: undefined

    Difference:

      Comparing two different types of values. Expected number but received undefined.

      2 |
      3 | test("letterCount works with regular strings", () => {
    > 4 |   expect(letterCount("awesome", "e")).toBe(2);
        |                                       ^
      5 | });
      6 |

      at Object.<anonymous>.test (__tests__/first.test.js:4:39)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.548s
Ran all test suites.

Our tests are still failing because we didnt implement the function, so let's do that! In our index.js, let's add:

function letterCount(str, char) {
  let count = 0;
  for (let letter of str) {
    if (letter === char) {
      count++;
    }
  }
  return count;
}

module.exports = letterCount;

We should now see the following in the Terminal:

 PASS  __tests__/first.test.js
  ✓ letterCount works with regular strings (3ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.746s, estimated 1s
Ran all test suites.

Your Turn

Try adding some more tests to watch for edge cases (capital vs lowercase letters, invalid input, etc)!

Matchers

Jest has quite a few functions used for assertions/expectations. You can see a full list here, but here are some common ones.

  • toBeDefined
  • toBeGreaterThan / toBeLessThan
  • toBe (uses === to compare)
  • toEqual (for deep object comparison)
  • toContain (see if a value is inside of a collection)

Let's try out a few more examples of the syntax in learn.test.js:

test("arithmetic", function() {
  expect(4 + 4).toBeGreaterThan(7);
  expect(4 + 4).toBeLessThan(9);
});

test("references", function() {
  var arr = [1, 2, 3];
  expect(arr).toEqual([1, 2, 3]);
  expect(arr).not.toBe([1, 2, 3]); // since === doesn't do deep comparison!
  expect(arr).toContain(1);
});

Integration Tests

So far we've been writing pretty simple unit tests, which is a good start, but when we're building APIs we'll most likely want to test how these units work together or how the integrate. To do that, we'll need to write some integration tests where we'll test that an HTTP request to our API yields the response we expect. To perform integration tests in our express application we'll be using a module called supertest.

Testing a simple API using jest and supertest

You can find the code for this entire example here

Let's start off building a very simple application - we'll add a database and more routes later, let's just focus on the syntax for now.

mkdir testing-with-express
cd testing-with-express
touch app.js
npm init -y
npm install --save-dev supertest
npm install express body-parser
mkdir __tests__
touch __tests__/students.test.js

Let's set up a very simple app.js

const express = require("express");
const app = express();
const bodyParser = require("body-parser");

app.use(bodyParser.json());

const students = ["Elie", "Matt", "Joel", "Michael"];

app.get("/", (req, res) => {
  return res.json(students);
});

app.listen(() => {
  console.log("Server starting on port 3000");
});

Now there's actually a problem here when we want to start testing! In our test file we are going to include the app variable from our app.js so that we can tell supertest which express application we are referring to. However, we are not currently exporting anything from our app.js. At the same time, if we require our app.js we will actually start the server using app.listen. So what we are going to do is make another file called server.js which will just contain our app.listen code and we will export out the app variable from our app.js - here's what our app.js should look like now:

const express = require("express");
const app = express();
const bodyParser = require("body-parser");

app.use(bodyParser.json());

const students = ["Elie", "Matt", "Joel", "Michael"];

app.get("/", (req, res) => {
  return res.json(students);
});

module.exports = app;

And here is what our server.js should look like:

const app = require("./app");

app.listen(3000, () => console.log("server starting on port 3000!"));

If we want to start our server we can run nodemon server.js and when we export our app variable we will not be starting the server! Now that we have this setup out of the way, let's write our first test with supertest!

Testing with Jest and Supertest

To get started with supertest, we need to include a bit of information in our students.test.js:

// we will use supertest to test HTTP requests/responses
const request = require("supertest");
// we also need our app for the correct routes!
const app = require("../app");

Now that we have that set up, let's write our first test to see what happens when we go to the root route. We're going to expect that we get back the response which is an array of strings (as defined in our app.js).

describe("GET / ", () => {
  test("It should respond with an array of students", async () => {
    const response = await request(app).get("/");
    expect(response.body).toEqual(["Elie", "Matt", "Joel", "Michael"]);
    expect(response.statusCode).toBe(200);
  });
});

If we run jest in the Terminal, we should see one test passing! Notice here that we are using supertest to make the HTTP request and getting a response from that request. In our app.js we simply were sending back an array of strings so we're just testing to see that we get that expected response as well as a 200 status code. This is working well, but as our application grows, we are going to include a database as well, so how do we go about testing data backed by a database? It's not too bad, it just requires a little bit of configuration!

Adding a test database

Now that we have an idea of how to test simple routes, let's include a database to store our students and test that we can do full CRUD on students! The first thing we need to do is a bit of file/folder setup, so in the root of our application, let's do the following in Terminal:

createdb students # this will be our development database
createdb students-test # this will be our test database
mkdir db
touch db/index.js
mkdir routes
touch routes/students.js

Now that we have our development and test databases, we need a way of telling our application that we are running tests or starting the development server. To do this, we'll use environment variables!

Fortunately, you don't have to worry about setting anything in a .env file as there is nothing sensitive here. Let's start in our db/index.js

const { Client } = require("pg");
const db = process.env.NODE_ENV === "test" ? "students-test" : "students";

client = new Client({
  connectionString: `postgresql://localhost/${db}`
});

client.connect();

module.exports = client;

Notice here we are first checking to see what the value of the NODE_ENV environment variable is. If it is "test", we will use the students-test database, otherwise we will use the students database. The rest of this code is what we have seen before, all we are doing now is determining if we should use the test or development database. When testing applications, you almost always will remove any data you start with so that your tests can be consistent, whereas in development you will want to remember certain data as you develop and manually test your application. So where do we set this NODE_ENV variable? Let's do that in our __tests__/students.test.js file!

process.env.NODE_ENV = "test";
const db = require("../db");

const request = require("supertest");
const app = require("../app");

These first two lines ensure that we are setting the NODE_ENV to test, and that we are brining in the export from db/index.js.

Setting Up and Tearing Down the test suite

Now that we are able to connect to the right database, we need to think about how to handle some data in our application. Here are some things we might want to do

  • Before all the tests run, create a table called students.
  • Before each test, seed the database with a couple rows of data.
  • After each test, delete all the rows in the students table (so we can be consistent).
  • After all the tests run, drop the table called students and close the database connection.

This process of "setup" and "teardown" are very common when testing applications. In Jest, these are done using four different functions:

  1. beforeAll - called once before all tests.
  2. beforeEach - called before each of these tests (before every test function).
  3. afterEach - called after each of these tests (after every test function).
  4. afterAll - called once after all tests.

Now that you have an idea of what they look like, let's add the following to our __tests__/students.test.js

process.env.NODE_ENV = "test";
const db = require("../db");
const request = require("supertest");
const app = require("../app");

beforeAll(async () => {
  await db.query("CREATE TABLE students (id SERIAL PRIMARY KEY, name TEXT)");
});

beforeEach(async () => {
  // seed with some data
  await db.query("INSERT INTO students (name) VALUES ('Elie'), ('Matt')");
});

afterEach(async () => {
  await db.query("DELETE FROM students");
});

afterAll(async () => {
  await db.query("DROP TABLE students");
  db.end();
});

Now that we have this initial setup working, let's go back to our routes/students.js file and add four simple routes for CRUD functionality. We're not going to worry about error handling, let's keep this example simple.

const express = require("express");
const router = express.Router();
const db = require("../db");

router.get("/", async (req, res) => {
  const data = await db.query("SELECT * FROM students");
  return res.json(data.rows);
});

router.post("/", async (req, res) => {
  const data = await db.query(
    "INSERT INTO students (name) VALUES ($1) RETURNING *",
    [req.body.name]
  );
  return res.json(data.rows[0]);
});

router.patch("/:id", async (req, res) => {
  const data = await db.query(
    "UPDATE students SET name=$1 WHERE id=$2 RETURNING *",
    [req.body.name, req.params.id]
  );
  return res.json(data.rows[0]);
});

router.delete("/:id", async (req, res) => {
  const data = await db.query("DELETE FROM students WHERE id=$1", [
    req.params.id
  ]);
  return res.json({ message: "Deleted" });
});

module.exports = router;

And now in our app.js, let's make sure to include these routes:

const express = require("express");
const app = express();
const db = require("./db");
const students = ["Elie", "Matt", "Joel", "Michael"];
const bodyParser = require("body-parser");
const studentRoutes = require("./routes/students");

app.use(bodyParser.json());
app.use("/students", studentRoutes);

app.get("/", (req, res) => {
  return res.json(students);
});

module.exports = app;

Now that we have this functionality in place, let's write some tests for each of these endpoints!

Integration Testing with Supertest

We previously saw how to test a simple route using supertest. Fortunately, we don't need to do too much more to test our GET /students route. Here is what that might look like:

describe("GET /students", () => {
  test("It responds with an array of students", async () => {
    const response = await request(app).get("/students");
    expect(response.body.length).toBe(2);
    expect(response.body[0]).toHaveProperty("id");
    expect(response.body[0]).toHaveProperty("name");
    expect(response.statusCode).toBe(200);
  });
});

Notice here we are expecting 2 records back, because in our beforeEach we seeded the database with two records. We're also expecting each record to have an id and name property. Now that we have read working, how would we test creating? Similar to the test above, we just need to send some data in this HTTP request.

describe("POST /students", () => {
  test("It responds with the newly created student", async () => {
    const newStudent = await request(app)
      .post("/students")
      .send({
        name: "New Student"
      });

    // make sure we add it correctly
    expect(newStudent.body).toHaveProperty("id");
    expect(newStudent.body.name).toBe("New Student");
    expect(newStudent.statusCode).toBe(200);

    // make sure we have 3 students now
    const response = await request(app).get("/students");
    expect(response.body.length).toBe(3);
  });
});

Notice here we are using the send method to populate req.body so that we can create a new record. Here we are expecting that our new student has a name and id that are correct and that we now have 3 students in our table.

Testing Update

Let's move onto update! Here we're going to test if we can create a record and update it successfully and make sure we still have 3 students in the table. Here's what that might look like:

describe("PATCH /students/1", () => {
  test("It responds with an updated student", async () => {
    const newStudent = await request(app)
      .post("/students")
      .send({
        name: "Another one"
      });
    const updatedStudent = await request(app)
      .patch(`/students/${newStudent.body.id}`)
      .send({ name: "updated" });
    expect(updatedStudent.body.name).toBe("updated");
    expect(updatedStudent.body).toHaveProperty("id");
    expect(updatedStudent.statusCode).toBe(200);

    // make sure we have 3 students
    const response = await request(app).get("/students");
    expect(response.body.length).toBe(3);
  });
});

Testing Delete

Let's move onto delete! Here we're going to test if we can delete a record, that the response is what we expect, and that we only have 2 students in the table. Here's what that might look like:

describe("DELETE /students/1", () => {
  test("It responds with a message of Deleted", async () => {
    const newStudent = await request(app)
      .post("/students")
      .send({
        name: "Another one"
      });
    const removedStudent = await request(app).delete(
      `/students/${newStudent.body.id}`
    );
    expect(removedStudent.body).toEqual({ message: "Deleted" });
    expect(removedStudent.statusCode).toBe(200);

    // make sure we still have 2 students
    const response = await request(app).get("/students");
    expect(response.body.length).toBe(2);
  });
});

If we run jest in the Terminal, we should see all of our tests passing!

You can find the code for this entire example here

Testing authentication and authorization

Now that we have a basic idea of how to test our CRUD operations, let's add some additional tests to make sure that users who are logged in (authenticated) can only perform CRUD operations on specific records (authorized).

Imagine we have the following routes:

  • POST /users/auth - returns a JWT when a successful username and password are sent
  • GET /users/ - returns all of the users, requires a valid JWT
  • GET /users/secure/:id - returns a simple messages, requires a valid JWT and the id in the URL has to match the id stored in the JWT

In this file, we'll write tests for GET /users and GET /users/secure/:id. In order to do that, we're going to need some setup that requires us to create a user, login a user, and remember the token and id stored in the token for testing. We'll store those pieces of information in a global object called auth.

process.env.NODE_ENV = "test";
const db = require("../db");
const request = require("supertest");
const app = require("../");
// for decoding the token and easily extracting the id from the payload
const jsonwebtoken = require("jsonwebtoken");
// for hashing the password successfully when we create users
const bcrypt = require("bcrypt");

// our global object for storing auth information
let auth = {};

// before everything - create the table
beforeAll(async () => {
  await db.query(
    "CREATE TABLE users (id SERIAL PRIMARY KEY, username TEXT, password TEXT)"
  );
});
// before each request, create a user and log them in
beforeEach(async () => {
  const hashedPassword = await bcrypt.hash("secret", 1);
  await db.query("INSERT INTO users (username, password) VALUES ('test', $1)", [
    hashedPassword
  ]);
  const response = await request(app)
    .post("/users/auth")
    .send({
      username: "test",
      password: "secret"
    });
  // take the result of the POST /users/auth which is a JWT
  // store it in the auth object
  auth.token = response.body.token;
  // store the id from the token in the auth object
  auth.current_user_id = jsonwebtoken.decode(auth.token).user_id;
});

// remove all the users
afterEach(async () => {
  await db.query("DELETE FROM users");
});

// drop the table and close the connection
afterAll(async () => {
  await db.query("DROP TABLE users");
  db.end();
});

describe("GET /users", () => {
  test("returns a list of users", async () => {
    const response = await request(app)
      .get("/users")
      // add an authorization header with the token
      .set("authorization", auth.token);
    expect(response.body.length).toBe(1);
    expect(response.statusCode).toBe(200);
  });
});

describe("GET /users without auth", () => {
  test("requires login", async () => {
    // don't add an authorization header with the token...see what happens!
    const response = await request(app).get("/users/");
    expect(response.statusCode).toBe(401);
    expect(response.body.message).toBe("Unauthorized");
  });
});

describe("GET /secure/:id", () => {
  test("authorizes only correct users", async () => {
    const response = await request(app)
      // add an authorization header with the token, but go to a different ID than the one stored in the token
      .get(`/users/secure/100`)
      .set("authorization", auth.token);
    expect(response.statusCode).toBe(401);
    expect(response.body.message).toBe("Unauthorized");
  });
});

describe("GET /secure/:id", () => {
  test("authorizes only correct users", async () => {
    const response = await request(app)
      // add an authorization header with the token, and go to the same ID as the one stored in the token
      .get(`/users/secure/${auth.current_user_id}`)
      .set("authorization", auth.token);
    expect(response.statusCode).toBe(200);
    expect(response.body.message).toBe("You made it!");
  });
});

When you're ready, move on to API Exercise

Continue

Creative Commons License