Separate Node.js unit, integration, and E2E tests using Jest projects

The classic test pyramid is made up of the unit, integration, and end-to-end tests. Unit tests are supposed to run fast after each change in your IDE and give immediate feedback. In contrast, integration and E2E tests are slow and are run on-demand. Sometimes you want to run all your tests, sometimes you want to run your E2E tests and nothing else. Fortunately, Jest provides a feature called projects that solves this problem relatively easily.

Whatever test pyramid you follow, you will always have faster-slower tests. So let's figure out how to separate them using Jest projects.

Initialize the project

Create an empty directory and initialize your Node.js project:

mkdir jest-projects
cd jest-projects
npm init -y

Of course, you will need Jest to run your tests, so install it as a development dependency:

npm install -D jest

By default, Jest treats any JS file in the __test__ directory as a test file, and Jest will try to run it. First, create the directory and create a test.js file.

mkdir __tests__
touch __tests__/test.js

Add a simple test

__tests__/test.js

it("test works", () => {
  expect(1 + 1).toBe(2);
});

Now ask Jest to run the test:

npx jest

You should see a successful test run.

 PASS  __tests__/test.js
  √ test works (2 ms)

Test Suites: 1 passed, 1 total                                                                                                                                                                                                                                                                                                                                                                                        
Tests:       1 passed, 1 total                                                                                                                                                                                                                                                                                                                                                                                        
Snapshots:   0 total
Time:        1.264 s
Ran all test suites.

Integration and E2E tests

Right now, Jest runs all the tests in the __tests__ folder. So let's create a separate directory for unit, integration, and E2E tests.

mkdir __tests__/unit
mkdir __tests__/integration
mkdir __tests__/e2e

Move the existing test.js file into the unit folder and copy-paste the file to the integration and e2e directory.

At this point, you should have the following directory tree:

├── __tests__
│   ├── e2e
│   │   └── test.js
│   ├── integration
│   │   └── test.js
│   └── unit
│       └── test.js
├── node_modules
├── package-lock.json
└── package.json

If you run Jest, you will see that all tests are run by default.

$ npx jest
 PASS  __tests__/integration/test.js
 PASS  __tests__/e2e/test.js
 PASS  __tests__/unit/test.js

Test Suites: 3 passed, 3 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.043 s
Ran all test suites.

Configure Jest projects

You will need four different configuration files. One configuration file will be the container file that references all your Jest projects. Rest three files are for unit, integration, and e2e projects.

jest.config.js

module.exports = {
  projects: [
    "<rootDir>/jest.unit.config.js",
    "<rootDir>/jest.integration.config.js",
    "<rootDir>/jest.e2e.config.js",
  ],
};

jest.unit.config.js

module.exports = {
  displayName: "unit",
  testMatch: ["**/__tests__/**/unit/**/*.[jt]s?(x)"],
};

jest.integration.config.js

module.exports = {
  displayName: "integration",
  testMatch: ["**/__tests__/**/integration/**/*.[jt]s?(x)"],
};

jest.e2e.config.js

module.exports = {
  displayName: "e2e",
  testMatch: ["**/__tests__/**/e2e/**/*.[jt]s?(x)"],
};

As you can see, these configuration files are very similar, except for the main configuration file that references the projects. These configuration files specify a display name and a glob pattern to match the test files.

Now, run your tests

$ npx jest
 PASS   integration  __tests__/integration/test.js
 PASS   e2e  __tests__/e2e/test.js
 PASS   unit  __tests__/unit/test.js

Test Suites: 3 passed, 3 total                                                                                                                                   
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.755 s, estimated 1 s
Ran all test suites in 3 projects.

Jest successfully recognized the three separate projects. You can see the display name of the project next to the file path.

What if you want to run specific tests? Jest CLI provides many options, but in this case, you should be interested in --selectProjects and --ignoreProjects.

Let's run e2e tests only

$ npx jest --selectProjects e2e
Running one project: e2e
 PASS   e2e  __tests__/e2e/test.js
  √ test works (1 ms)

Test Suites: 1 passed, 1 total                                                                                                                                   
Tests:       1 passed, 1 total                                                                                                                                   
Snapshots:   0 total
Time:        0.363 s, estimated 1 s
Ran all test suites.

Or run all tests, except e2e tests

$ npx jest --ignoreProjects e2e
Running 2 projects:
- integration
- unit
 PASS   integration  __tests__/integration/test.js
 PASS   unit  __tests__/unit/test.js

Test Suites: 2 passed, 2 total                                                                                                                                   
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.559 s, estimated 1 s
Ran all test suites in 2 projects.

NPM scripts

You will probably get tired of typing the Jest commands all the time. So write a few NPM scripts so your fingers can get some rest.

package.json

{
  "name": "jest-projects",
  "version": "1.0.0",
  "scripts": {
    "test": "jest",
    "test:unit": "jest --selectProjects unit",
    "test:integration": "jest --selectProjects integration",
    "test:e2e": "jest --selectProjects e2e"
  },
  "devDependencies": {
    "jest": "^28.1.3"
  }
}

This will provide you with four scripts:

  • npm test- Runs all tests (unit, integration and E2E)
  • npm run test:unit - Runs unit tests only
  • npm run test:integration - Runs integration tests only
  • npm run test:e2e - Runs E2E tests only

Global setup and teardown modules

Most of the time, integration and E2E tests require some kind of setup and teardown before the test suite. Jest provides globalSetup and globalTeardown options in the configuration file. Let's set it up for the E2E tests.

jest.e2e.config.js

module.exports = {
  displayName: "e2e",
  testMatch: ["**/__tests__/**/e2e/**/*.[jt]s?(x)"],
  testPathIgnorePatterns: [
    "<rootDir>/__tests__/e2e/_setup.js",
    "<rootDir>/__tests__/e2e/_teardown.js",
  ],
  globalSetup: "<rootDir>/__tests__/e2e/_setup.js",
  globalTeardown: "<rootDir>/__tests__/e2e/_teardown.js",
};

This config will tell Jest to run the __tests__/e2e/_setup.js before all E2E test suites. And run <rootDir>/__tests__/e2e/_teardown.js after all E2E test suites.

Now, you might wonder why testPathIgnorePatterns contains the _setup and _teardown files. Take a look at the testMatch's value. It matches any .js, .ts, .jsx and .tsx files in any e2e directory. Therefore this configuration simply matches the setup and teardown files as well. If you don't ignore them, Jest will complain that these files don't contain any tests.

Create the setup and teardown modules

__tests__/e2e/_setup.js

module.exports = () => {
  console.log("E2E setup");
};

__tests__/e2e/_teardown.js

module.exports = () => {
  console.log("E2E teardown");
};

Now, if you run your E2E tests, you will see the messages in your terminal

$ npm run test:e2e

> [email protected] test:e2e
> jest --selectProjects e2e

Running one project: e2e
Determining test suites to run...E2E setup
 PASS   e2e  __tests__/e2e/test.js
  √ test works (1 ms)

Test Suites: 1 passed, 1 total                                                                                                                                   
Tests:       1 passed, 1 total                                                                                                                                   
Snapshots:   0 total
Time:        0.336 s, estimated 1 s
Ran all test suites.
E2E teardown

Tests by filename suffixes

You might prefer a different setup for your test files. Some projects use different directories, some projects colocate tests, and some projects just use naming conventions to differentiate between tests.

Previously, you separated your tests by using different directories. Now let's assume you want to do the following:

  • my-test.unit.js - should be in the unit project
  • my-test.integration.js - should be in the integration project
  • my-test.e2e.js - should be in the e2e project

Create an empty lib directory, this is where you will store your source code files:

mkdir lib

Write a simple add function:

lib/add.js

module.exports.add = (a, b) => a + b;

Create a unit, integration, and E2E test file for this simple add function.

lib/add.unit.js

const { add } = require("./add");

it("add(1, 1) should return 2", () => {
  expect(add(1, 1)).toBe(2);
});

lib/add.int.js

const { setTimeout } = require("node:timers/promises");
const { add } = require("./add");

it("add(1, 1) should return 2 (1s delay)", async () => {
  await setTimeout(1000);
  expect(add(1, 1)).toBe(2);
});

lib/add.e2e.js

const { setTimeout } = require("node:timers/promises");
const { add } = require("./add");

it("add(1, 1) should return 2 (3s delay)", async () => {
  await setTimeout(3000);
  expect(add(1, 1)).toBe(2);
});

Integration and E2E tests include a delay to simulate the slowness of these tests.

At this point, if you try to run any of your test commands, you notice that none of these are running any of the new tests. Let's modify the configuration files to run colocated test files as well in the lib directory.

jest.unit.config.js

module.exports = {
  displayName: "unit",
  testMatch: [
    "**/__tests__/**/unit/**/*.[jt]s?(x)",
    "<rootDir>/lib/**/*.unit.[jt]s?(x)",
  ],
};

jest.integration.config.js

module.exports = {
  displayName: "integration",
  testMatch: [
    "**/__tests__/**/integration/**/*.[jt]s?(x)",
    "<rootDir>/lib/**/*.int.[jt]s?(x)",
  ],
};

jest.e2e.config.js

module.exports = {
  displayName: "e2e",
  testMatch: [
    "**/__tests__/**/e2e/**/*.[jt]s?(x)",
    "<rootDir>/lib/**/*.e2e.[jt]s?(x)",
  ],
  testPathIgnorePatterns: [
    "<rootDir>/__tests__/e2e/_setup.js",
    "<rootDir>/__tests__/e2e/_teardown.js",
  ],
  globalSetup: "<rootDir>/__tests__/e2e/_setup.js",
  globalTeardown: "<rootDir>/__tests__/e2e/_teardown.js",
};

Each configuration has a new element in the testMatch array. Each matches its respective desired file naming format. Note that in this case <rootDir>/lib is used. So the lib directory must reside in the root directory of your project to be matched. Whereas **/__tests__ matches nested ___tests__ directories as well.

Now, let's test what you have worked on

$ npm test

> [email protected] test
> jest

Determining test suites to run...E2E setup
 PASS   unit  __tests__/unit/test.js
 PASS   integration  __tests__/integration/test.js
 PASS   e2e  __tests__/e2e/test.js                                                                                                                               
 PASS   unit  lib/add.unit.js                                                                                                                                    
 PASS   integration  lib/add.int.js
 PASS   e2e  lib/add.e2e.js

Test Suites: 6 passed, 6 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        4.086 s
Ran all test suites in 3 projects.
E2E teardown

Jest now runs the tests in the lib directory without any issues! The test took 4 seconds due to delays in the new integration and E2E tests.

Now try to run unit tests only

$ npm run test:unit

> [email protected] test:unit
> jest --selectProjects unit

Running one project: unit
 PASS   unit  lib/add.unit.js
 PASS   unit  __tests__/unit/test.js

Test Suites: 2 passed, 2 total                                                                                                                                   
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.542 s, estimated 1 s
Ran all test suites.

It took half a second, and no integration and E2E tests were run. Looks good!

You are done! Great job!

The project's source code is available on GitHub.

Check out the official documentation to learn more about Jest projects: