DEV Community

Akinwale Folorunsho Habibullah
Akinwale Folorunsho Habibullah

Posted on

How to improve test isolation in integration testing using Jest and testcontainers

Test Containers

Integration testing is a software testing technique that verifies the interactions and data exchange between different components or modules of a software application. It aims to identify any problems or bugs that arise when different components are combined and interact with each other. Integration tests provide better guarantee than unit tests but come at a cost, which is speed.

Integration tests are slower than unit tests because they interact with software dependencies and possibly wait for long running asynchronous or I/O intensive processes to complete, such as database queries. Integration tests however provide better guarantees than unit tests, run less often and have a broader scope including many units and dependencies.

In a bid to make our integration tests faster, we can run them in parallel. Jest runs tests in parallel by default, using the number of available CPU cores. Running tests in parallel does save time, but we must ensure our tests are well isolated from each other and have very little to no shared resources.

If tests are not properly isolated or have too many shared resources, you can have a test that invalidates the state of another test. For example, a test can write in a MongoDB database collection, while another test deletes all the documents in the same MongoDB database collection as part of its setup, possibly in the beforeEach hook.

The first thing we need to do regarding database integrations in our test is to use a separate database instance.

This gives us a good starting point because we don't risk polluting or deleting our development data or in the worst case scenario, production data.

Using dotenv, we can easily use a separate test specific database instance by having an environment specific dotenv file such as test.env, dev.env etc. In our application, we can then read the relevant env file like this:


require('dotenv').config({ path: `${process.env.NODE_ENV}.env` })
......

const MONGODB_URI = process.env.MONGODB_URI;
mongoose.connect(MONGODB_URI);

Enter fullscreen mode Exit fullscreen mode

When running your application in test mode with Jest (identified by the NODE_ENV variable being set to test), the application will automatically load the test-specific environment file. This file can be used to configure your application for testing, including connecting to a dedicated test database.

In simpler terms, Jest automatically switches to the test environment settings when running your tests. This allows you to use a separate database for testing purposes, ensuring your tests are isolated and don't affect your development or production data.

Tests in separate files could however still invalidate each other's state if they both access the same database collection. Given that tests run in parallel, we cannot be sure of the order the tests will run, and it it's generally a bad practice to rely on the order of execution in our tests, because we can never be sure.

A solution is to run tests sequentially. To run tests sequentially, use the --runInBand option when running Jest. When using the option with npm script, you need to prefix the option with -- before using any option, like this

npm run test -- --runInBand
Enter fullscreen mode Exit fullscreen mode

when running Jest

jest --runInBand
Enter fullscreen mode Exit fullscreen mode

JEST_WORKER_ID is set to 1 for all tests when runInBand is set to true, and rightly so because we wont have more than one worker process. Only one test will run at a time before execution proceeds to the next test.

Running tests sequentially solves our problem, but at the cost of speed. Meaning out tests will run slower.

Another option is to keep running our tests in parallel mode but first check if the application is running in test environment, and then add change the MongoDB connection string to make sure the database we are connecting to is unique for each test.

let connection
let { MONGODB_URI, NODE_ENV, JEST_WORKER_ID } = process.env
if (NODE_ENV === 'test') {
  MONGODB_URI = `${MONGODB_URI}_${JEST_WORKER_ID}`
}
mongoose.connect(databaseUrl);

...
module.exports = {
  connection,
  server
};

Enter fullscreen mode Exit fullscreen mode

Besides setting the environment variable to test, Jest also sets the JEST_WORKER_ID environment variable. Each worker process is assigned a unique id (index-based that starts with 1). Using JEST_WORKER_ID, we can ensure we are using a unique database when making connections to the database.

With MongoDB's creation on first use, we don't need to do anything besides connecting to the unique database. If you use a relational database in your application, where the database has to exist first before connecting to it, then you will need to connect to the database using a default database and superadmin or root user, execute create database DDL query first, and then switch connection to the newly created unique database.

It is worthy to note that a testable application is designed with testing in mind, so checking and then manipulating the connection string if its a test context in our application is not entirely bad, but I will say inconvenient.

We can get rid of having to provision a database for testing purposes, so that its a self contained application especially in a test context. This makes integration tests easier especially in CI/CD. This is where testcontainers shines. It is an open source framework for providing throwaway, lightweight instances of databases, message brokers, web browsers, or just about anything that can run in a Docker container.

To get started, add testcontainers as a dev dependency.


npm install testcontainers --save-dev

Enter fullscreen mode Exit fullscreen mode

Then remove the the MONGODB_URI and its value in your test.env configuration file.

We then need to create a MongoDB container and get the connection details to the container. We also need to inject the MongoDB connection string into the app, so the application can connect to MongoDB database as it would when running in any other environment apart from test.

First, let us create a MongoDB container for our tests.

const { GenericContainer } = require('testcontainers')

....

let container;
let app
let connection

beforeAll(async () => {
  container = await new GenericContainer('mongo')
    .withExposedPorts(27017)
    .start()

const connectionUrl = `mongodb://${container.getHost()}:${container.getMappedPort(27017)}/kittytest`
process.env.MONGODB_URI = connectionUrl
app = require('./app').server
connection = require('./app').connection
});

Enter fullscreen mode Exit fullscreen mode

In the beforeAll hook of our test suite, we created a MongoDB container using a GenericContainer from testcontainers. All we needed to do create the container was tell GenericContainer to create a container of type mongo. You will agree with me that this declarative approach to creating a container is terser than using a Dockerfile. We then read the host using the getHost method, and the exposed port using the getMappedPort method.

To inject the MongoDB container's connection string into our application, we create the MongoDB string using the details we read from the container and formatted it to match a MongoDB connection string format. We then added this connection string to process.env.MONGODB_URI, which the app reads from the process.env values, after dotenv must have loaded some environment variables from dotenv files.

Lastly, we imported our app and mongoose connection and saved them into app and connection global variables, so we can close both after all tests finish running.

We delayed importing the application till this point, after we have added the MongoDB connection string to the process.env. If we imported the app before this, the value of MONGODB_URI will be null, and the application will not be able to connect to a database, causing our tests to fail.

Now we can run our integration tests, and our application will run smoothly against a MongoDB container provided by testcontainer.

To ensure we don't leave any resources open or processes running which will leave Jest hanging, we can use the afterAll Jest hook.


afterAll(async () => {
  app.close()
  connection.close()
  await container.stop()
});

Enter fullscreen mode Exit fullscreen mode

Besides closing both our express application, and mongoose connection, we also need to stop our container using container.stop(). The stop method is an asynchronous method, so we use await to wait for the container to shut down before execution proceeds.

In this article, we saw how to roperly isolate tests when doing integration testing and also create a MongoDB container using testcontainers. We also learnt how to guarantee that each test file will run against a unique MongoDB database. We also used testcontainers to replace our test specific MongoDB database.

I hope you find this article informative and useful. Happy testing.

Top comments (0)