Parallel tests in Node.js with Jest and MongoDB (without mocking)
Tags: testing, mongodb, javascript
Published: 2019-10-22 23:57:15

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](https://github.com/dyarleniber/parallel-tests).

> 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.

```shell
jest --runInBand
```

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

Let's start by installing some dependencies.

```shell
yarn add express mongoose dotenv
```

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
```javascript
const app = require('./app');

app.listen(process.env.NODE_PORT || 3000);
```
---

###### src/app.js
```javascript
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;
```
---

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
```javascript
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;
```
---

###### src/app/helpers/database.js
```javascript
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,
};
```
---

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
```javascript
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);
```
---

###### src/app/models/Task.js
```javascript
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);
```
---

###### src/app/controllers/UserController.js
```javascript
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();
```
---

###### src/app/controllers/TaskController.js
```javascript
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();
```
---

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

```shell
yarn add jest supertest mongodb-memory-server -D
```

**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](https://github.com/nodkz/mongodb-memory-server).

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

###### src/lib/MemoryDatabaseServer.js
```javascript
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();
```
---

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:

```shell
yarn jest --init
```

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:

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

**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
```javascript
const MemoryDatabaseServer = require('../src/lib/MemoryDatabaseServer');

module.exports = async () => {
  await MemoryDatabaseServer.start();
};
```
---

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

###### \_\_tests\_\_/teardown.js
```javascript
const MemoryDatabaseServer = require('../src/lib/MemoryDatabaseServer');

module.exports = async () => {
  await MemoryDatabaseServer.stop();
};
```
---

**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
```javascript
require('dotenv').config();

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

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

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

afterAll(() => {
  return databaseHelper.disconnect();
});
```
---

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

###### \_\_tests\_\_/environment.js
```javascript
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;
```
---

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
```javascript
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);
  });
});
```
---

###### \_\_tests\_\_/integration/task.test.js
```javascript
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);
  });
});
```
---

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:

```javascript
{
  "scripts": {
    "dev": "node src/server.js",
    "test": "NODE_ENV=test jest"
  }
}
```

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

```shell
yarn test
```

Obrigado!