DEV Community

Cover image for Parallel tests in Node.js with Jest and MongoDB (without mocking)
Dyarlen Iber
Dyarlen Iber

Posted on

Parallel tests in Node.js with Jest and MongoDB (without mocking)

It's a common practice when writing tests, provide a mock implementation for your database, the problem with this approach is that, if there is any error in a query, this error will never be caught.
In this post I will demonstrate how to run tests in parallel using a shared database without mocking. We will develop a rest API with some routes and integration tests.

All the source code developed in this post can be found in this GitHub repository.

First of all, if you don't have many tests, you might consider not running the tests in parallel, Jest has an option that allows test suites to run in series. Just use the --runInBand option, and you can use a Docker image to run a new instance of the database during testing.

jest --runInBand
Enter fullscreen mode Exit fullscreen mode

However, if you have many tests this is definitely not a good option for you.

Let's start by installing some dependencies.

yarn add express mongoose dotenv
Enter fullscreen mode Exit fullscreen mode

Now, let's create 2 files: app.js and server.js, inside a folder called src, which should contain all the source code of our application.

In the src/app.js file, we will create a new express instance that starts a server, and the src/server.js file will listens for connections on the specified host and port, we won't use this last file for testing, just to start development environment or production environment.

src/server.js
const app = require('./app');

app.listen(process.env.NODE_PORT || 3000);
Enter fullscreen mode Exit fullscreen mode

src/app.js
require('dotenv').config();

const express = require('express');

const routes = require('./routes');

const databaseHelper = require('./app/helpers/database');

class App {
  constructor() {
    this.express = express();

    this.database();
    this.middlewares();
    this.routes();
  }

  database() {
    databaseHelper.connect();
  }

  middlewares() {
    this.express.use(express.json());
  }

  routes() {
    this.express.use(routes);
  }
}

module.exports = new App().express;
Enter fullscreen mode Exit fullscreen mode

The database, middlewares and routes settings were set in the src/app.js file, the routes and the database configurations will be imported from other files (we'll talk about models and controllers later).
For testing purposes we will create only 2 routes (create and delete) for each resource (users and tasks).

src/routes.js
const express = require('express');

const UserController = require('./app/controllers/UserController');
const TaskController = require('./app/controllers/TaskController');

const routes = new express.Router();

routes.post('/users', UserController.store);
routes.delete('/users', UserController.delete);
routes.post('/tasks', TaskController.store);
routes.delete('/tasks', TaskController.delete);

module.exports = routes;
Enter fullscreen mode Exit fullscreen mode

src/app/helpers/database.js
const mongoose = require('mongoose');

const connect = async () => {
  if (mongoose.connection.readyState === 0) {
    await mongoose.connect(
      process.env.NODE_ENV === 'test' ? global.__DB_URL__ : process.env.DB_URL,
      {
        useNewUrlParser: true,
        useCreateIndex: true,
        useFindAndModify: false,
        useUnifiedTopology: true,
      }
    );
  }
};

const truncate = async () => {
  if (mongoose.connection.readyState !== 0) {
    const { collections } = mongoose.connection;

    const promises = Object.keys(collections).map(collection =>
      mongoose.connection.collection(collection).deleteMany({})
    );

    await Promise.all(promises);
  }
};

const disconnect = async () => {
  if (mongoose.connection.readyState !== 0) {
    await mongoose.disconnect();
  }
};

module.exports = {
  connect,
  truncate,
  disconnect,
};
Enter fullscreen mode Exit fullscreen mode

The database helper will be used later for testing.
Realize that, if the environment is test, the MongoDB URI used will be the one stored in the global variable __DB_URL__, in other environments, the environment variable DB_URL will be used. We'll talk about this again in a moment.

To finalize the development of the API, we will create the models the controllers.

src/app/models/User.js
const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema(
  {
    name: {
      type: String,
      required: true,
    },
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
    },
    password: {
      type: String,
      required: true,
    },
  },
  {
    timestamps: true,
  }
);

module.exports = mongoose.model('User', UserSchema);
Enter fullscreen mode Exit fullscreen mode

src/app/models/Task.js
const mongoose = require('mongoose');

const TaskSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: true,
    },
    description: {
      type: String,
      required: true,
    },
  },
  {
    timestamps: true,
  }
);

module.exports = mongoose.model('Task', TaskSchema);
Enter fullscreen mode Exit fullscreen mode

src/app/controllers/UserController.js
const User = require('../models/User');

class UserController {
  async store(req, res) {
    try {
      const user = new User({
        name: req.body.name,
        email: req.body.email,
        password: req.body.password,
      });

      await user.save();

      return res.json({
        id: user._id,
        name: user.name,
        email: user.email,
      });
    } catch (err) {
      return res.status(500).json({ error: 'Internal server error' });
    }
  }

  async delete(req, res) {
    try {
      const user = await User.findById(req.body.id);

      if (user) {
        await user.remove();
      }

      return res.send();
    } catch (err) {
      return res.status(400).json({ error: 'User not found' });
    }
  }
}

module.exports = new UserController();
Enter fullscreen mode Exit fullscreen mode

src/app/controllers/TaskController.js
const Task = require('../models/Task');

class TaskController {
  async store(req, res) {
    try {
      const task = new Task({
        title: req.body.title,
        description: req.body.description,
      });

      await task.save();

      return res.json(task);
    } catch (err) {
      return res.status(500).json({ error: 'Internal server error' });
    }
  }

  async delete(req, res) {
    try {
      const task = await Task.findById(req.body.id);

      if (task) {
        await task.remove();
      }

      return res.send();
    } catch (err) {
      return res.status(400).json({ error: 'Task not found' });
    }
  }
}

module.exports = new TaskController();
Enter fullscreen mode Exit fullscreen mode

Now, we will begin the development of our test environment. Let's start by installing our development dependencies.

yarn add jest supertest mongodb-memory-server -D
Enter fullscreen mode Exit fullscreen mode

Jest will be our test runner and SuperTest will help us with integration testing. And the mongodb-memory-server will be very useful for starting a new dedicated MongoDB instance for each test suite. Let's talk later about configuring this dependency, but you can read more about it here.

Let's create a class to encapsulate all the configuration needed for the mongodb-memory-server.

src/lib/MemoryDatabaseServer.js
const { MongoMemoryServer } = require('mongodb-memory-server');

class MemoryDatabaseServer {
  constructor() {
    this.mongod = new MongoMemoryServer({
      binary: {
        version: '4.0.3',
      },
      autoStart: false,
    });
  }

  start() {
    return this.mongod.start();
  }

  stop() {
    return this.mongod.stop();
  }

  getConnectionString() {
    return this.mongod.getConnectionString();
  }
}

module.exports = new MemoryDatabaseServer();
Enter fullscreen mode Exit fullscreen mode

In the constructor method we create a new instance of the MongoMemoryServer, and we can provide some options, in this case we will set a binary version of MongoDB, and the autoStart option to false avoids the automatic download of the binary at the moment we instantiate the class, so the download will be made only when we call the start method defined below. The stop method should be called at the end of all tests.

On install, the mongodb-memory-server package downloads the latest MongoDB binaries and saves it to a cache folder. Then, when the start method is invoked, if the binary cannot be found, it will be auto-downloaded. So, the first run may take some time. All further runs will be fast, because they will use the downloaded binaries.

The getConnectionString method will be responsible for returning a valid MongoDB URI for each test suite, the return of this method will be stored in the global variable __DB_URL__ mentioned before.

About Jest, we can create a jest settings file using the following command:

yarn jest --init
Enter fullscreen mode Exit fullscreen mode

At the end, there should be a file called jest.config.js in the project root. Let's make some modifications in the following attributes:

{
  globalSetup: '<rootDir>/__tests__/setup.js',
  globalTeardown: '<rootDir>/__tests__/teardown.js',
  setupFilesAfterEnv: ['<rootDir>/__tests__/setupAfterEnv.js'],
  testEnvironment: '<rootDir>/__tests__/environment.js',
  testMatch: ['**/__tests__/**/*.test.js']
}
Enter fullscreen mode Exit fullscreen mode

testMatch is used by Jest to detect test files.

globalSetup is a path to a module which exports an async function that is triggered once before all test suites.

__tests__/setup.js
const MemoryDatabaseServer = require('../src/lib/MemoryDatabaseServer');

module.exports = async () => {
  await MemoryDatabaseServer.start();
};
Enter fullscreen mode Exit fullscreen mode

globalTeardown is a path to a module which exports an async function that is triggered once after all test suites.

__tests__/teardown.js
const MemoryDatabaseServer = require('../src/lib/MemoryDatabaseServer');

module.exports = async () => {
  await MemoryDatabaseServer.stop();
};
Enter fullscreen mode Exit fullscreen mode

setupFilesAfterEnv is a list of paths to modules that run some code to configure or set up the testing framework before each test.

__tests__/setupAfterEnv.js
require('dotenv').config();

const databaseHelper = require('../src/app/helpers/database');

beforeAll(() => {
  return databaseHelper.connect();
});

beforeEach(() => {
  return databaseHelper.truncate();
});

afterAll(() => {
  return databaseHelper.disconnect();
});
Enter fullscreen mode Exit fullscreen mode

testEnvironment is the test environment that will be used for testing.

__tests__/environment.js
const NodeEnvironment = require('jest-environment-node');

const MemoryDatabaseServer = require('../src/lib/MemoryDatabaseServer');

class CustomEnvironment extends NodeEnvironment {
  async setup() {
    await super.setup();

    this.global.__DB_URL__ = await MemoryDatabaseServer.getConnectionString();
  }

  async teardown() {
    await super.teardown();
  }

  runScript(script) {
    return super.runScript(script);
  }
}

module.exports = CustomEnvironment;
Enter fullscreen mode Exit fullscreen mode

We are almost done. Now, we'll develop the tests for our routes, the SuperTest will be used for the integration tests.

__tests__/integration/user.test.js
const supertest = require('supertest');

const app = require('../../src/app');

const UserModel = require('../../src/app/models/User');

const request = supertest(app);

describe('User', () => {
  it('should be able to create user', async () => {
    const response = await request.post('/users').send({
      name: 'userName',
      email: 'useremail@email.com',
      password: '123123',
    });

    expect(response.status).toBe(200);
  });

  it('should be able to delete user', async () => {
    const user = new UserModel({
      name: 'existsUserName',
      email: 'existsUseremail@email.com',
      password: '123123',
    });

    await user.save();

    const response = await request.delete('/users').send({
      id: user._id,
    });

    expect(response.status).toBe(200);
  });
});
Enter fullscreen mode Exit fullscreen mode

__tests__/integration/task.test.js
const supertest = require('supertest');

const app = require('../../src/app');

const TaskModel = require('../../src/app/models/Task');

const request = supertest(app);

describe('Task', () => {
  it('should be able to create task', async () => {
    const response = await request.post('/tasks').send({
      title: 'taskTitle',
      description: 'taskDescription',
    });

    expect(response.status).toBe(200);
  });

  it('should be able to delete task', async () => {
    const task = new TaskModel({
      title: 'existsTaskTitle',
      description: 'existsTaskDescription',
    });

    await task.save();

    const response = await request.delete('/tasks').send({
      id: task._id,
    });

    expect(response.status).toBe(200);
  });
});
Enter fullscreen mode Exit fullscreen mode

In the package.json file we must configure the test script to set the environment variable before calling jest, and you can use the src/server.js file mentioned before to start a development environment, like this:

{
  "scripts": {
    "dev": "node src/server.js",
    "test": "NODE_ENV=test jest"
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, just run the following command to start the tests:

yarn test
Enter fullscreen mode Exit fullscreen mode

Obrigado!

Top comments (2)

Collapse
 
asaf_s profile image
Asaf S • Edited

First of all, thx for this very helpful manual!

Can you explain, if the DB is being entirely deleted before each test, how can the tests run in parallel? I can see it works, but there's only 1 DB instance, so they must be waiting for the previous one to finish, no?

I was looking for an example in which each test creates a new MongoMemoryServer instance to use (only 7 MB in RAM they say)...

Collapse
 
vimanyuagg profile image
Vimanyu Aggarwal

Thanks for putting together this article! I'm curious to know how would you test the 500 error responses with this approach.