Running multiple Jest projects from a single configuration is a powerful way to manage complex monorepos or simply keep your tests organized across different parts of your codebase.
Let’s see it in action. Imagine you have a monorepo with two distinct packages, lib-a and lib-b, and you want to run tests for both from the root.
Here’s a simplified package.json at the root of your monorepo:
{
"name": "my-monorepo",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "jest"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
And here’s your root jest.config.js:
module.exports = {
projects: [
'./lib-a/jest.config.js',
'./lib-b/jest.config.js',
],
// Optional: root-level config can be merged or overridden by project configs
// collectCoverage: true,
// coverageDirectory: 'coverage/root',
};
Now, let’s look at the individual project configurations.
lib-a/jest.config.js:
module.exports = {
displayName: 'lib-a',
rootDir: './lib-a',
testMatch: ['<rootDir>/src/**/*.test.js'],
moduleNameMapper: {
'^@lib-a/(.*)$': '<rootDir>/src/$1',
},
// Other specific config for lib-a
};
lib-b/jest.config.js:
module.exports = {
displayName: 'lib-b',
rootDir: './lib-b',
testMatch: ['<rootDir>/tests/**/*.spec.ts'],
preset: 'ts-jest', // Example: using a preset for TypeScript
// Other specific config for lib-b
};
When you run npm test (or yarn test) from the root, Jest will discover these projects and execute them independently. The output will clearly demarcate which project’s tests are running, thanks to the displayName setting.
The core problem this solves is managing test execution and configuration across multiple, potentially independent, code modules within a single repository. Instead of having a monolithic Jest configuration trying to account for everything, you delegate. Each jest.config.js file within a sub-project handles its own specific needs – its root directory, its test file patterns, its module aliases, its presets, and even its own coverage reporting settings.
Internally, when Jest encounters the projects array in the root configuration, it doesn’t try to merge them into one giant configuration. Instead, it spawns separate Jest worker processes, each running with one of the configurations specified in the projects array. This isolation is key. It means that a moduleNameMapper or testMatch defined in lib-a’s config will only apply to tests run by lib-a’s worker, and won’t interfere with lib-b. The root configuration acts primarily as an orchestrator, a list of which sub-projects to run. It can also provide default settings that are then inherited or overridden by the individual project configurations.
The rootDir option is crucial here. For lib-a, rootDir: './lib-a' tells Jest that the root of this specific project’s filesystem is the lib-a directory. This means <rootDir> in lib-a/jest.config.js resolves to the absolute path of ./lib-a from the monorepo root. This allows testMatch: ['<rootDir>/src/**/*.test.js'] to correctly find tests within lib-a/src. Similarly, moduleNameMapper: { '^@lib-a/(.*)$': '<rootDir>/src/$1' } correctly maps imports like @lib-a/utils to lib-a/src/utils.
The displayName is purely for user experience, making the output much clearer when multiple projects are running simultaneously. Without it, you’d just see a stream of test results, and it might be hard to tell which project produced which output.
When you run tests for multiple projects, Jest orchestrates these separate runs. It collects results from each project and presents them as a combined report. If you have collectCoverage: true at the root level, Jest will attempt to aggregate coverage reports from all the sub-projects into a single directory specified by coverageDirectory. However, it’s often more practical to configure coverage reporting independently within each sub-project’s Jest configuration to avoid conflicts or unexpected merging.
The magic of this setup lies in the fact that Jest handles the process spawning and result aggregation for you. You just need to provide the list of project configurations. This pattern is commonly used with monorepo tools like Lerna or Yarn Workspaces, where each package is essentially its own Jest project.
The most surprising thing about this setup is how little Jest actually merges configurations. It doesn’t attempt to create a single, unified configuration object by deeply merging all the settings. Instead, it treats each entry in the projects array as a distinct, independent Jest invocation, effectively running jest --config ./lib-a/jest.config.js and jest --config ./lib-b/jest.config.js in parallel and then combining their outputs. This means that some configuration options, like coverageDirectory, will behave independently for each project unless explicitly handled by a root-level setting that understands how to aggregate them.
The next concept you’ll likely encounter is how to handle shared configurations or common test setups across these multiple projects, perhaps using Jest’s setFromConfig or by abstracting common options into a base configuration file.