cloud · web

Building a Custom Integration Testing Environment in Jest

May 15th, 2025
imgimgimgimg
Building a Custom Integration Testing Environment in Jest

When writing integration tests in Jest, we usually need to connect to out-of-process dependencies like a database, a message broker or a cache. While Jest provides lifecycle methods like beforeAll and afterAll which can handle setup and teardown of these dependencies and their connections, it can add maintenance overhead if we need to remember to run the setup and teardown methods inside every individual test file. There are also some situations where the teardown logic in afterAll doesn't get run, such as when an exception occurs in the code and the test process exits early.

In this article, we'll see how Jest test environments can simplify this process and make it so all the setup and teardown is handled in a single place. We'll also look at how test containers can make it easier to run lightweight drop-in replacements for our out-of-process dependencies programmatically.

Jest Test Environments

A Jest test environment is a module that defines how the tests are run. By default it uses node, but this can be set to jsdom for web testing. Unlike beforeAll and afterAll, this is completely separate from the test suites and can be used to run global logic that customises the test execution environment.

What the Jest Docs Say The test environment that will be used for testing. The default environment in Jest is a Node.js environment. If you are building a web app, you can use a browser-like environment through jsdom instead.

We're going to look at examples covering the following five things:

  • A custom test environment - integration test runner - that runs custom setup and teardown logic for the overall test execution environment. We expect to run Jest with maxWorkers greater than one, which means we'll have multiple test suites running at the same time, so each test suite will load the custom test environment individually. The setup and teardown methods of the custom test environment will run once for each test suite.
  • A globalSetup file that runs some initialisation logic once and once only, before any test suite runs.
  • A globalTeardown file that runs some initialisation logic once and once only, after all the test suites have run.
  • A jest-preset.js showing how to configure Jest to use the custom test environment along with the globalSetup.ts and globalTeardown.ts files
  • A redis.ts file showing a simple example of how to launch a Redis test container using the testcontainers library

Code Samples

Integration Test Runner

Our integrationTestRunner.ts file contains a class which extends the TestEnvironment class in the jest-environment-node package.

In this example, we have a setup method which is responsible for creating a totally new database for each test suite execution. We clone a so-called "template DB" which holds all the table definitions, indexes and foreign key constraints. This allows each test suite to be executed in complete isolation from the others that are running at the same time (recall how we said we'd be using a maxWorkers value greater than one).

There is also a teardown method which removes the new DB clone and closes the connection to our database and cache.

Note In the example below, we are using a getDbName method to figure out which database to remove after the test suite finishes running. It's assumed that this will have some unique identifier linking it to this particular Jest worker, most likely based on the JEST_WORKER_ID environment variable.

// integrationTestRunner.ts import { TestEnvironment } from 'jest-environment-node'; import type { EnvironmentContext, JestEnvironmentConfig, } from '@jest/environment'; import { Sequelize } from 'sequelize'; import cloneTemplateDb from '../setup/cloneTemplateDb'; import { dbConnectionFactory, getDbName } from './dbConnectionFactory'; import redis from './cache'; export default class IntegrationTestRunner extends TestEnvironment { private templateDbConnection: Sequelize; constructor(config: JestEnvironmentConfig, context: EnvironmentContext) { super(config, context); this.templateDbConnection = dbConnectionFactory(); } async setup() { await super.setup(); await cloneTemplateDb(this.templateDbConnection); } async teardown() { const shutdownJobs = [this.shutdownMySql(), this.shutdownRedis()]; await Promise.all(shutdownJobs); await super.teardown(); } async shutdownMySql() { // getDbName should use some unique identifier like the JEST_WORKER_ID // environment variable to ensure the database is isolated from other // parallel test executions await this.templateDbConnection.query( `DROP DATABASE IF EXISTS \`${getDbName()}\`;`, ); await this.templateDbConnection.close(); } async shutdownRedis() { await redis.close(); } }

Our globalSetup.ts file only runs once - before Jest has run any test suites. It is responsible for starting our test container - MySQL and Redis - and setting up our template database by running migrations. Recall that our custom test environment above will clone the template database for each parallel test suite execution to maintain isolation between tests.

Note To handle Jest watch mode, where one or more test suites can be rerun without fully exiting the Jest process, the below snippet contains custom logic which makes sure we don't rerun database migrations every time a test suite is rerun.

Global Setup

// globalSetup.ts import { isEmpty } from 'lodash'; import logger from '@mylogger'; import { startMySql, startRedis, } from '../../setup/containers'; import runDbMigrations from '../../setup/runMigrations'; global.testContainers = []; let hasInitialisedWatchModeDb = false; export default async ({ watch, watchAll }) => { const isWatchModeEnabled = watch || watchAll; const { DB_NAME } = process.env; try { if (isEmpty(testContainers)) { const containers = await Promise.all([ startMySql(), startRedis(), ]); testContainers.push(...containers); } // When running in watch mode, we only want to run the migrations on the first test run. // Future runs should save time and resources by reusing the database in its existing state. if (isWatchModeEnabled) { if (hasInitialisedWatchModeDb) { logger.info('Skipping DB setup in watch mode...'); return; } const sequelize = require('sequelize'); await sequelize.query(`DROP DATABASE IF EXISTS \`${DB_NAME}\``); const { stderr } = await runMySqlMigrations(); if (stderr) { logger.error(`Watch mode DB migration error output: ${stderr}`); } hasInitialisedWatchModeDb = true; return; } // In regular Jest execution mode (not watch mode), run a fresh set of migrations on every // test run. const { stderr } = await runDbMigrations(); if (stderr) { logger.error(`Database migration error output: ${stderr}`); } } catch (err) { logger.error(`Error during global setup: ${err}`); process.exit(1); } };

Global Teardown

Our global teardown logic is shown below. Recall that this only runs once - after all test suites have finished running. As such, this is responsible for cleaning up connections to out-of-process dependencies. In our case, this means shutting down our Redis and MySQL connections.

Note that we account for Jest watch mode by skipping the connection cleanup logic if the tests are being run in watch mode.

// globalTeardown.ts import logger from 'mylogger'; import { shutdownMySql, shutdownRedis, } from '../../teardown/closeConnections'; module.exports = async ({ watch, watchAll }) => { const isWatchModeEnabled = watch || watchAll; // Don't run the teardown logic if we're in watch mode. if (isWatchModeEnabled) { return; } try { await shutdownRedis(); await shutdownMySql(); } catch (err) { logger.error(`Error during global teardown: ${err}`); process.exit(1); } };

Jest Config Preset

The code snippet below shows how to configure Jest to use the files we defined above. We export a set of JSON config as a preset so it can be reused elsewhere, which is ideal in a monorepo setup.

// jest-preset.js const path = require('path'); const config = { globalSetup: path.resolve(__dirname, 'globalSetup.ts'), globalTeardown: path.resolve(__dirname, 'globalTeardown.ts'), maxConcurrency: 1, // Only applies to tests that use .concurrent testEnvironment: path.resolve( __dirname, '../../environments/integrationTestRunner.ts', ), testTimeout: 30000, transformIgnorePatterns: ['node_modules'], }; export default config;

Starting Test Containers

The following code snippet shows how we can launch a Redis container programmatically using the testcontainers library. We extract the mapped port into the REDIS_PORT environment variable so that the tests can connect to Redis on the host machine.

// start-redis.ts import { RedisContainer } from '@testcontainers/redis'; export default async () => { const redisContainer = await new RedisContainer().start(); process.env.REDIS_PORT = redisContainer.getFirstMappedPort(); return redisContainer; };

Conclusion

In this article we have seen how Jest test environments can be used to customise the execution context of our integration test suites. We saw how to achieve database isolation even when test suites are executed in parallel, as well as how to accommodate Jest watch mode without breaking those guarantees.

Share this itemimgimgimgimg

Related Articles

Using AWS DMS to Depersonalise Sensitive Data

The AWS Database Migration Service is usually associated with the transfer…

September 12th, 2025

How to Import AWS Lambda Functions to Terraform

Sometimes, when working at a small scale or in a team that is new to the…

February 27th, 2023

A CI/CD Pipeline using CodeBuild, RDS and Route53

CodePipeline is a managed product that can be used to create an automated…

August 9th, 2022

Logo
James Does Digital
Software Development
Cloud Computing
Current Address
Edinburgh
Scotland
UK
This site was created using the Jamstack.
All articles © James Does Digital 2026. All rights reserved.