×
By the end of this chapter, you should be able to:
jest
and supertest
to write unit and integration testsJest 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
.
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.
Try adding some more tests to watch for edge cases (capital vs lowercase letters, invalid input, etc)!
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); });
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
.
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!
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!
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
.
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
This process of "setup" and "teardown" are very common when testing applications. In Jest, these are done using four different functions:
beforeAll
- called once before all tests.beforeEach
- called before each of these tests (before every test
function).afterEach
- called after each of these tests (after every test
function).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!
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.
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); }); });
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
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 sentGET /users/
- returns all of the users, requires a valid JWTGET /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 JWTIn 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