How to Thoroughly Test Your Express.js and Mongoose Apps with Jest and SuperTest

Testing is an integral part of the software development process that helps ensure the quality, reliability and maintainability of your application. The earlier you incorporate testing into your workflow, the more confident you can be that your app will function as intended. This is especially crucial for backend APIs powering modern web and mobile apps.

In this in-depth guide, we‘ll walk through how to effectively test Node.js apps built with the popular Express.js framework and Mongoose ODM (Object Document Mapper) for MongoDB. We‘ll be using the Jest testing framework and SuperTest library to write and run our tests.

The Tools of the Trade

Before diving into the code, let‘s take a quick look at the key tools and libraries we‘ll be working with:

Express.js

Express is a minimal, flexible and battle-tested web framework for Node.js. It provides a robust set of features for building web applications and APIs, such as routing, middleware, template engines and more.

Mongoose

Mongoose is an ODM library for MongoDB and Node.js. It manages relationships between data, provides schema validation, and translates between objects in code and their representation in MongoDB.

Jest

Jest is a delightful JavaScript testing framework created by Facebook. It works out of the box with minimal configuration, has an intuitive and easy-to-read syntax, provides helpful failure messages, and can perform both unit and integration tests.

SuperTest

SuperTest is a powerful HTTP assertions library that allows you to test your Node.js HTTP servers. It provides a high-level abstraction for testing HTTP requests, without having to deal with the nitty-gritty details of the underlying networking.

Now that we‘ve met the cast, let‘s set the stage by creating an example Express app to test.

Setting Up an Express App

For our example, let‘s build a simple Express API for managing a collection of books. We‘ll implement CRUD (Create, Read, Update, Delete) endpoints for interacting with the books data.

First, create a new directory for the project and initialize a new Node.js package:

mkdir express-testing-demo
cd express-testing-demo
npm init -y

Next, install the necessary dependencies:

npm install express mongoose

Create a server.js file with the following code to set up the Express app and connect to a MongoDB database:

const express = require(‘express‘);
const mongoose = require(‘mongoose‘);

const app = express();

mongoose.connect(‘mongodb://localhost/bookstore‘, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
});

const db = mongoose.connection;
db.on(‘error‘, console.error.bind(console, ‘connection error:‘));
db.once(‘open‘, () => {
  console.log(‘Connected to MongoDB‘);
});

app.use(express.json());

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

module.exports = app;

Now define a Mongoose schema and model for a Book in models/book.js:

const mongoose = require(‘mongoose‘);

const bookSchema = new mongoose.Schema({
  title: String,
  author: String,
  genre: String,
  publicationYear: Number,
});

module.exports = mongoose.model(‘Book‘, bookSchema);

Finally, add the route handlers for the books API endpoints in routes/books.js:

const express = require(‘express‘);
const router = express.Router();
const Book = require(‘../models/book‘);

// Get all books
router.get(‘/‘, async (req, res) => {
  const books = await Book.find();
  res.json(books);
});

// Get single book by ID  
router.get(‘/:id‘, async (req, res) => {
  const book = await Book.findById(req.params.id);
  res.json(book);
});

// Create a new book
router.post(‘/‘, async (req, res) => {
  const book = new Book(req.body);
  await book.save();
  res.status(201).json(book); 
});

// Update a book
router.put(‘/:id‘, async (req, res) => {
  const book = await Book.findByIdAndUpdate(req.params.id, req.body, {
    new: true,
  });
  res.json(book);
});

// Delete a book  
router.delete(‘/:id‘, async (req, res) => {
  await Book.findByIdAndDelete(req.params.id);
  res.sendStatus(204);
});

module.exports = router;

Make sure to import and use the books router in server.js:

const booksRouter = require(‘./routes/books‘);
app.use(‘/books‘, booksRouter);  

With our example Express app set up, let‘s move on to writing some tests!

Writing Tests with Jest and SuperTest

First, install Jest and SuperTest as development dependencies:

npm install --save-dev jest supertest  

Jest looks for test files in a __tests__ directory by default, so create that directory and add a test file for the books API routes:

mkdir __tests__
touch __tests__/books.test.js

Open up books.test.js and start by importing the necessary modules and the Express app:

const request = require(‘supertest‘);
const mongoose = require(‘mongoose‘);
const app = require(‘../server‘);
const Book = require(‘../models/book‘);

Before each test, we want to connect to the MongoDB database and make sure we start with a clean slate by removing any existing books. We can do this in a beforeEach block:

beforeEach(async () => {
  await mongoose.connect(‘mongodb://localhost/bookstore‘, {
    useNewUrlParser: true,
    useUnifiedTopology: true,
  });
  await Book.deleteMany({}); 
});

After all the tests have run, we should close the database connection. Add this code to an afterAll block:

afterAll(async () => {  
  await mongoose.connection.close();
});

Now we‘re ready to write our first test! Let‘s verify that the endpoint to get all books returns an empty array when there are no books in the database:

describe(‘GET /books‘, () => {
  it(‘should return an empty array when there are no books‘, async () => {
    const res = await request(app).get(‘/books‘);

    expect(res.statusCode).toBe(200);
    expect(res.body).toEqual([]);  
  });
});  

Here we‘re using SuperTest‘s request function to send a GET request to the /books endpoint. We then make assertions about the response using Jest‘s expect API.

Let‘s add a test for creating a new book via the POST endpoint:

describe(‘POST /books‘, () => {
  it(‘should create a new book‘, async () => {
    const res = await request(app)
      .post(‘/books‘)
      .send({
        title: ‘Test Book‘,
        author: ‘Test Author‘,
        genre: ‘Test Genre‘,
        publicationYear: 2023,
      });

    expect(res.statusCode).toBe(201);
    expect(res.body.title).toBe(‘Test Book‘);
    expect(res.body.author).toBe(‘Test Author‘); 
    expect(res.body.genre).toBe(‘Test Genre‘);
    expect(res.body.publicationYear).toBe(2023);
  });
});

In this test, we send a POST request to /books with the data for a new book in the request body. We assert that the response has a 201 status code and that the response body contains the data we sent.

We can also test that the book was actually saved to the database by querying for it using Mongoose:

const savedBook = await Book.findOne({ title: ‘Test Book‘ });
expect(savedBook.author).toBe(‘Test Author‘); 
expect(savedBook.genre).toBe(‘Test Genre‘);
expect(savedBook.publicationYear).toBe(2023);

Let‘s move on to testing the endpoint to get a single book by ID:

describe(‘GET /books/:id‘, () => {
  it(‘should return a book by ID‘, async () => {
    const book = new Book({
      title: ‘Test Book‘,
      author: ‘Test Author‘,
      genre: ‘Test Genre‘, 
      publicationYear: 2023,
    });
    await book.save();

    const res = await request(app).get(`/books/${book._id}`);

    expect(res.statusCode).toBe(200);
    expect(res.body.title).toBe(‘Test Book‘);
    expect(res.body.author).toBe(‘Test Author‘);
    expect(res.body.genre).toBe(‘Test Genre‘);
    expect(res.body.publicationYear).toBe(2023);
  });

  it(‘should return a 404 if the book is not found‘, async () => {
    const res = await request(app).get(‘/books/60e7a1f0a0d0c9a0b0e0e0e0‘);

    expect(res.statusCode).toBe(404);
  }); 
});

In the first test, we create a new book, save it to the database, and then send a GET request to /books/:id with the ID of the saved book. We assert that the response contains the correct book data.

The second test verifies that a 404 status is returned when trying to get a non-existent book by ID.

Next up, the PUT endpoint for updating a book:

describe(‘PUT /books/:id‘, () => { 
  it(‘should update a book‘, async () => {
    const book = new Book({
      title: ‘Test Book‘,
      author: ‘Test Author‘,
      genre: ‘Test Genre‘,
      publicationYear: 2023,
    });
    await book.save();

    const res = await request(app)
      .put(`/books/${book._id}`)
      .send({
        title: ‘Updated Test Book‘,
        author: ‘Updated Test Author‘,
        genre: ‘Updated Test Genre‘,
        publicationYear: 2024,
      });

    expect(res.statusCode).toBe(200);
    expect(res.body.title).toBe(‘Updated Test Book‘);
    expect(res.body.author).toBe(‘Updated Test Author‘);
    expect(res.body.genre).toBe(‘Updated Test Genre‘);   
    expect(res.body.publicationYear).toBe(2024);

    const updatedBookInDb = await Book.findById(book._id);
    expect(updatedBookInDb.title).toBe(‘Updated Test Book‘);
    expect(updatedBookInDb.author).toBe(‘Updated Test Author‘);
    expect(updatedBookInDb.genre).toBe(‘Updated Test Genre‘);
    expect(updatedBookInDb.publicationYear).toBe(2024);
  });
});

Similar to the GET by ID test, we first create and save a book. We then send a PUT request to /books/:id with the update data in the request body. We assert that the response contains the updated book data. As an extra check, we fetch the book from the database and verify it was actually updated.

Finally, let‘s test deleting a book:

describe(‘DELETE /books/:id‘, () => {
  it(‘should delete a book‘, async () => {
    const book = new Book({
      title: ‘Test Book‘,
      author: ‘Test Author‘, 
      genre: ‘Test Genre‘,
      publicationYear: 2023,
    });
    await book.save();

    const res = await request(app).delete(`/books/${book._id}`);

    expect(res.statusCode).toBe(204);

    const deletedBook = await Book.findById(book._id);
    expect(deletedBook).toBeNull();
  });
});

We create and save a book, send a DELETE request to /books/:id, and assert that the response has a 204 status. We also try to fetch the deleted book from the database and expect it to be null.

And with that, we‘ve covered testing all the CRUD endpoints of our example Express API! Of course, in a real-world app you would want to add many more test cases and edge cases.

Tips and Best Practices

Here are some tips and best practices to keep in mind when testing your Express and Mongoose apps:

  • Aim for good test coverage of your most important code paths and edge cases. Tools like Jest‘s built-in coverage reporter can help you track this.
  • Keep your tests focused and modular. Each test should ideally check one specific thing.
  • Use descriptive test names that clearly communicate what is being tested.
  • Take advantage of Jest‘s setup and teardown hooks like beforeEach, afterEach, beforeAll, afterAll for test setup and cleanup.
  • Consider adding integration tests that test the entire flow of your API endpoints, from request to database operations to response.
  • Use a separate test database to avoid polluting your development or production databases during tests.
  • If your app talks to external services, consider mocking those services in your tests to have more control and avoid flakiness.

Conclusion

Testing is a crucial skill for any developer to have in their toolkit. By combining Express with Jest and SuperTest, you can create robust test suites for your Node.js backend applications.

In this guide, we walked through setting up an example Express app with Mongoose, and writing integration tests for CRUD API endpoints using SuperTest. We covered testing response status codes, response bodies, and database operations.

I hope this gives you a solid foundation to start testing your own Express and Mongoose apps with confidence! The more you practice, the more naturally testing will come to you. Happy testing!

Similar Posts