When writing software, it can be tedious to test features manually and ensure your entire program continues to work as it evolves over time. We can use automated testing to invoke our program automatically, and ensure it works as expected.
The Purpose of Testing
Simply put, writing good tests that verify your program works as expected will help you catch regressions, and improve the reliability of your software. Good tests will ensure the program meets the requirements the program needs to meet, and that it will continue to do so as the program evolves over time. Software testing is also integral to some software development processes like TDD and BDD, though these are outside the scope of this article.
The Node.js ecosystem has a large variety of testing frameworks to choose from, and many serve many different purposes. We'll be focusing on using Mocha and Chai to demonstrate the basics of testing in the context of backend development.
What are Mocha and Chai?
Mocha is a JavaScript testing framework that runs on Node.js, and in the browser. It is one of the most popular test frameworks in use in the Node.js community, and is very easy to learn and understand.
Chai is an assertion library that helps extend test frameworks like Mocha to make writing certain kinds of tests easier. Most test frameworks like Mocha also come with their own assertion functions as well, but we've opted to show off Chai since it makes it easier to write more complex tests.
Installing Mocha and Friends
Mocha and Chai can be installed into your project using the following:
npm install mocha chai --save-dev
Also, note that all following examples are available on GitHub for your viewing pleasure. I won't be showing all code being tested in this article as it would be a lot. You can then define a scripts
section in your package.json
file to run your tests so you can run your tests with npm test
.
{
"name": "mocha-basic",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "mocha"
},
"author": "",
"license": "ISC",
"devDependencies": {
"mocha": "^8.3.2"
}
}
The Basics
We will start by showing off one of the most basic tests that you can make with Mocha, and bear with me as I'll show off some more complex tests later. This is a basic unit test that calls a function that returns the number 42, and tests that the result comes back as expected. The following is defined in a file called index.js
.
exports.meaningOfLife = () => {
return 42;
};
And the test for this function can be written as follows in a file located at test/index.test.js
(note the naming convention).
const assert = require("assert");
const { meaningOfLife } = require("../index");
describe("Index", function () {
describe("#meaningOfLife", function () {
it("should return 42", function () {
const result = meaningOfLife();
assert.strictEqual(result, 42);
});
});
});
Tests can be run by invoking npm test
in your project root. I've opted to keep all tests in a directory called test
under the project root, and have test files for each corresponding source file with an added test.js
suffix at the end of the filename. There are a few layers to this test, so let's go through each one.
Tests are defined by calling global Mocha functions that are defined when the mocha
command is invoked. describe
is used for organizing your tests. The way that I'm using it above is one level for what I'm testing in general, this being the index file, the meaningOfLife
function, and then all tests that need to invoke that function. In this case there's only one test since this is a very simple function.
Tests are written inside of callbacks passed into the it
function. One thing to note is how the test reads out like plain English. This is meant to make the purpose of the test to be more clear to the reader. In this example, we simply import and call the meaningOfLife
function and check that it equals 42. We also use the assert
call from Mocha to do this comparison, as it will raise an exception if the two expressions don't match.
Let's test an API
To demonstrate more practical tests, I've opted to create a basic API that returns data, and tests that the data has the expected format. The API is a simple express application that returns an array of movies as JSON by calling GET /movies
against it.
Testing an API isn't as simple as calling a function, and checking a response, so we will use chai
and supertest
to help us. The tests are as follows:
const chai = require("chai");
const request = require("supertest");
const expect = chai.expect;
const app = require("../../app");
describe("Movies", function () {
describe("GET /movies", function () {
it("should return 200 OK with several movies", async function () {
const response = await request(app)
.get("/movies")
.expect(200)
.expect("Content-Type", /json/);
const movies = response.body;
expect(movies).to.be.an("array");
expect(movies).length.to.be.greaterThan(0);
});
it("should have valid movies", async function () {
const response = await request(app)
.get("/movies")
.expect(200)
.expect("Content-Type", /json/);
const movies = response.body;
expect(movies).to.be.an("array");
movies.forEach(movie => {
expect(movie.name).to.be.a("string");
expect(movie.year).to.be.a("number");
expect(movie.rating).to.be.a("number");
expect(movie.description).to.be.a("string");
expect(movie.director).to.be.a("string");
expect(movie.genres).to.be.an("array");
});
});
});
});
Chai, as mentioned before, is an assertion library, and we use it here primarily for type checking the data coming back from the API. We use SuperTest to make API calls. We pass our app
object to SuperTest, and it handles making sure the API is listening, and gives us access to some useful HTTP request builders that include assertion functions as well.
We import the expect
function from Chai, and use it instead of the built-in Mocha ones. The tests read out like plain English, and are very easy to understand. Chai actually has several interfaces that allow you to write tests such as "should" and "assert". These are detailed on their website.
Hooks
Imagine that the API we're testing has a persistent database of some kind. The data stored in these database will naturally persist between tests, and this can be bad if we want tests to be reproducible. To solve this problem, we can use hooks. Hooks allow code to be run before and after test blocks.
In this case we want to use them to initialize and destroy the database the API will be using for each test to ensure the state is consistent every time a test is run, and will always have the same result.
Here is an example of the beforeEach
and afterEach
hooks in action.
const chai = require("chai");
const request = require("supertest");
const expect = chai.expect;
const app = require("../../app");
const db = require("../../db");
describe("Movies", function () {
// Create the schema before each test in the describe block.
beforeEach(async function () {
const database = await db.database;
await db.initDB(database);
});
// Drop the schema before each test in the describe block.
afterEach(async function () {
const database = await db.database;
await db.resetDB(database);
});
describe("GET /movies", function () {
it("should return 200 OK with several movies", async function () {
...
});
it("should have valid movies", async function () {
...
});
});
describe("GET /movies/:id", function () {
it("should query an individual movie", async function () {
...
});
});
describe("POST /movies", function () {
it("should create a new movie", async function () {
...
});
});
describe("PATCH /movies/:id", function () {
it("should update an existing movie", async function () {
...
});
});
describe("DELETE /movies/:id", function () {
it("should delete an existing movie", async function () {
...
});
});
});
I've removed the contents of the actual tests since they're a bit long, but they're in the GitHub repository if you're curious. The details of the db
and app
modules are outside the context of this article.
The main takeaway here is the code inside of the beforeEach
hook will tell Mocha to create the database before each test, and the code in the afterEach
hook will tell Mocha to destroy the database after each test. I should also mention it
and describe
are also hooks as well!
Mocha Hooks:
it
: Defines a single test.describe
: Defines a block of tests.before
: Run only once before the first test in the describe block.after
: Run only once after the last test in the describe block.beforeEach
: Runs before every test in the describe block.afterEach
: Runs after every test in the describe block.
Conclusion
Effective use of testing libraries in Node.js like Mocha and Chai can help make it easy to write tests and make your application more reliable. The example projects used throughout this article can be found on my GitHub. I have only scratched the surface here when it comes to testing. There are also frontend testing frameworks such as Selenium and Cypress. We've actually written about Cypress on our blog as well, and you can find that here if you're curious.