Jay Gould

Testing on Node.js with Jest and a Sequelize test database

July 28, 2020

Jest testing

Using a testing framework like Jest or Mocha provides a consistent way to test a web system, but there are so many ways to approach testing that the nuances make it difficult to get that consistent process ready to use. One aspect of testing which can be handled in different ways is the data store, and whether to use mock data or databases. I have done this a few different ways in the past, such as abstracting the database away as a dependency so a “fake” database can be used, or using a real development database.

At work we use a real database but a separate test one, providing a clean datastore which simplifies the testing process. Whilst integrating this setup into a project recently I ran into some issues running this test database with Sequelize, and performing tests in Jest, so I wanted to document the approach here.

My project is using Typescript but there’s not much extra that I’ve had to implement because of Typescript, so I’ll include some TS bits as well as normal JS.

Prerequisites

This post assumes prior experience with Node, Postgres, Sequelize and Jest.

Installing dependencies

If you haven’t done so already, install the dependencies required:

npm i sequelize sequelize-cli jest jest-cli faker supertest ts-jest

The ts-jest is only required if you’re running Typescript, and enables Jest to parse .ts files.

Environment setup

As mentioned above, this testing approach requires a specific database for testing. Before each test is initiated, the database will be wiped and migrations done. That means it’s ideal to have a separate database for both dev and testing. The easiest way to do this is with Docker Compose.

If you are using Docker Compose, you’ll want something like the following for setting up the dev and test databases:

version: "3"
services:
    ...
    api:
        build: ./api
        command: sh -c "npm run db-migrate && npm run run-dev"
        container_name: pc-api-dev
        environment:
            DB_USER: user
            DB_PASSWORD: password
            DB_NAME: next-ts-jwt-boilerplate
            DB_HOST: db
            DB_PORT: 5432
            DB_TEST_HOST: db-test
        ports:
            - 3001:3001
        volumes:
            - ./api:/home/app/api
            - /home/app/api/node_modules
        working_dir: /home/app/api
        restart: on-failure
        depends_on:
            - db
            - db-test
    db:
        image: postgres
        environment:
            - POSTGRES_USER=user
            - POSTGRES_DB=next-ts-jwt-boilerplate
            - POSTGRES_PASSWORD=password
            - POSTGRES_HOST_AUTH_METHOD=trust
        volumes:
            - ./db/data/postgres:/var/lib/postgresql/data
        ports:
            - 5432:5432
    db-test:
        image: postgres
        environment:
            - POSTGRES_USER=user
            - POSTGRES_DB=next-ts-jwt-boilerplate
            - POSTGRES_PASSWORD=password
            - POSTGRES_HOST_AUTH_METHOD=trust
        volumes:
            - ./db/data-test/postgres:/var/lib/postgresql/data
        ports:
            - 5430:5432

The ports in each database service definition will be the ports to enter into your database viewer to see the databases within the Docker containers from your host machine using localhost.

If you aren’t using Docker Compose, you’ll need to set up 2 databases manually on your machine.

Either way, take note of the connection env details as they will be needed next.

Sequelize setup

This post assumes you have Sequelize set up already, so I’ll give a brief overview of how I’ve integrated it. The database config and other supporting files can be found here for more detail, but the Sequelize config file will look something like this:

// src/db/models/index

import { config } from "dotenv"
config({ path: "./../.env" })

module.exports = {
  development: {
    database: process.env.DB_NAME,
    username: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    host: process.env.DB_HOST,
    dialect: "postgres",
    logging: false,
  },
  test: {
    database: process.env.DB_NAME,
    username: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    host: process.env.DB_TEST_HOST,
    dialect: "postgres",
    logging: false,
  },
  production: {},
}

The test credentials there will need to match the test database you have on your system now.

The end goal is to have the test database emptied and migrations done before the tests are ran. Sequelize allows database migrations and seeds using the sequelize-cli package (sequelize db:migrate), which is great for running migrations on dev and production, but there’s no dedicated CLI command for emptying the database first. Emptying the database is possible on the CLI but it looks very messy and is too complicated for what it’s worth.

Instead, Sequelize offers a handy sync() function which empties the database and runs all migrations, but this can only be executed from a file.

This is where Jest config comes in…

Jest config

First, you’ll want to make sure you’ve followed the official Jest advice and split out the app and server functionality into separate files. This is because tests are more efficient when ran using the bare bones of the app rather than spinning up a whole server.

// app.ts

// Get dependencies
import express from 'express';
import db from './db/models';

...

// Route handlers
const authApi = require('./v1/auth');

// Create server
const app: express.Application = express();

// API routes
app.use('/v1/auth', authApi);

export { app };

I’ve cut out a lot of the supplementary includes above, but the basic structure is important.

// server.ts

import { app } from "./app"

const server = app.listen(app.get("port"), () => {})

And the app is included in to the server and used to create the server for dev and production.

So back to the testing, and the most important part which is the test file:

// auth.test.ts

const { app } = require("../../app")
import db from "../../db/models"

import * as faker from "faker"
import supertest from "supertest"

import { Authentication } from "../../services/Authentication"

describe("test the JWT authorization middleware", () => {
  // Set the db object to a variable which can be accessed throughout the whole test file
  let thisDb: any = db

  // Before any tests run, clear the DB and run migrations with Sequelize sync()
  beforeAll(async () => {
    await thisDb.sequelize.sync({ force: true })
  })

  it("should succeed when accessing an authed route with a valid JWT", async () => {
    const authentication = new Authentication()
    const randomString = faker.random.alphaNumeric(10)
    const email = `user-${randomString}@email.com`
    const password = `password`

    await authentication.createUser({ email, password })

    const { authToken } = await authentication.loginUser({
      email,
      password,
    })

    // App is used with supertest to simulate server request
    const response = await supertest(app)
      .post("/v1/auth/protected")
      .expect(200)
      .set("authorization", `bearer ${authToken}`)

    expect(response.body).toMatchObject({
      success: true,
    })
  })

  it("should fail when accessing an authed route with an invalid JWT", async () => {
    const invalidJwt = "OhMyToken"

    const response = await supertest(app)
      .post("/v1/auth/protected")
      .expect(400)
      .set("authorization", `bearer ${invalidJwt}`)

    expect(response.body).toMatchObject({
      success: false,
      message: "Invalid token.",
    })
  })

  // After all tersts have finished, close the DB connection
  afterAll(async () => {
    await thisDb.sequelize.close()
  })
})

This solves a couple of problems - firstly, the app object is included in each test file so supertest can make a server request. Our tests look for specific response codes and messages, so testing like this is crucial.

The Sequelize sync() function mentioned earlier is executed at the start of the test, before any tests have ran. This is required because a clean and migrated database is needed to get the full benefit of the testing. It stops us from accidentally overwriting or removing information from another test, and means our tests can be ran with precision.

The thisDb variable (instance of the clean, migrated database) is then used after all of the tests have finished to close the connection. This stops that annoying message when an async action has not finished correctly:

Jest error

Running tests sequentially

With the approach above, it’s not possible to run tests in parallel because the database instance is the same one being imported in to each test file. Trying to run all tests in parallel (Jest default) means the database will be closing while the next test is being executed. To get around this, the package.json file can be updated to run the following Jest script:

  "scripts": {
    ...
    "test": "NODE_ENV=test jest --runInBand",
  }

The --runInBand means the tests will be executed sequentially, so the database is cleaned, migrated and closed in between each test suite. This makes the test time a little slower, but not by much, and I think it’s worth doing so you are able to use a simple and usable testing approach with a dedicated test database.

Improvements

In a larger or production app, testing time may be an important factor. If so, the above implementation might not suit the needs. Making the tests run faster will demand parallel testing.

The reason this project doesn’t run parallel testing is because the Sequelize database is emptied and migrated at the start of each test. An alternative solution would be to empty and migrate the database once at the start of the whole test, and close that same one instance at the end of each test. This solution would mean feeding in the same database instance to all tests in between though, which is not hard to do, but it’s a different setup to what my project is running.

The above can be done using Jest global setup and global teardown.


Senior Engineer at Haven

© Jay Gould 2023, Built with love and tequila.