Support jest.config.mts (#16005)

This commit is contained in:
Manthan Ziman
2026-05-04 09:55:40 +02:00
committed by GitHub
parent 1a29f8a43a
commit 0be53522b7
13 changed files with 158 additions and 6 deletions
+1
View File
@@ -4,6 +4,7 @@
- `[babel-jest]` Support collecting coverage from `.mts`, `.cts` (and other) files ([#15994](https://github.com/jestjs/jest/pull/15994))
- `[jest-circus, jest-cli, jest-config, jest-core, jest-jasmine2, jest-types]` Add `--collect-tests` flag to discover and list tests without executing them ([#16006](https://github.com/jestjs/jest/pull/16006))
- `[jest-config]` Add support for `jest.config.mts` as a valid configuration file ([#16005](https://github.com/jestjs/jest/pull/16005))
- `[jest-mock]` Add `clearMocksOnScope(scope)` on `ModuleMocker` for clearing every mock function exposed on a scope object ([#16088](https://github.com/jestjs/jest/pull/16088))
- `[jest-resolve]` Add `canResolveSync()` on `Resolver` so callers can detect when a user-configured resolver only exports an `async` hook ([#16064](https://github.com/jestjs/jest/pull/16064))
- `[jest-runtime]` Use synchronous `evaluate()` for ES modules without top-level `await` on Node versions that support it (v24.9+), and prefer the synchronous transform path when a sync transformer is configured ([#16062](https://github.com/jestjs/jest/pull/16062))
+1 -1
View File
@@ -5,7 +5,7 @@ title: Configuring Jest
The Jest philosophy is to work great by default, but sometimes you just need more configuration power.
It is recommended to define the configuration in a dedicated JavaScript, TypeScript or JSON file. The file will be discovered automatically, if it is named `jest.config.js|ts|mjs|cjs|cts|json`. You can use [`--config`](CLI.md#--configpath) flag to pass an explicit path to the file.
It is recommended to define the configuration in a dedicated JavaScript, TypeScript or JSON file. The file will be discovered automatically, if it is named `jest.config.js|ts|mjs|mts|cjs|cts|json`. You can use [`--config`](CLI.md#--configpath) flag to pass an explicit path to the file.
:::note
+21
View File
@@ -69,6 +69,27 @@ describe('readInitialOptions', () => {
},
);
onNodeVersions('^22.18 || >=23.6', () => {
test('should read mts-config/jest.config.mts file', async () => {
const configFile = resolveFixture('mts-config', 'jest.config.mts');
const rootDir = resolveFixture('mts-config');
const {config, configPath} = await proxyReadInitialOptions(undefined, {
cwd: rootDir,
});
expect(config).toEqual({jestConfig: 'jest.config.mts', rootDir});
expect(configPath).toEqual(configFile);
});
});
onNodeVersions('<22.18', () => {
test('should fail to read mts-config/jest.config.mts with a clear error', async () => {
const rootDir = resolveFixture('mts-config');
await expect(
proxyReadInitialOptions(undefined, {cwd: rootDir}),
).rejects.toThrow(/jest\.config\.mts requires native TypeScript support/);
});
});
test('should be able to skip config reading, instead read from cwd', async () => {
const expectedConfigFile = resolveFixture(
'json-config',
+44
View File
@@ -17,6 +17,7 @@ afterAll(() => cleanup(DIR));
// Node.js's builtin TypeScript support doesn't do type checking, so skip tests
// that validate type-checking behavior.
const testIfTsLoader = useNativeTypeScript ? test.skip : test;
const testIfNativeTypeScript = useNativeTypeScript ? test : test.skip;
describe('when `Config` type is imported from "@jest/types"', () => {
test('with object config exported from TS file', () => {
@@ -97,6 +98,49 @@ describe('when `Config` type is imported from "@jest/types"', () => {
expect(globalConfig.verbose).toBe(true);
});
testIfNativeTypeScript(
'with object config exported from MTS file when package.json#type=commonjs',
() => {
writeFiles(DIR, {
'__tests__/dummy.test.js':
"test('dummy', () => expect(123).toBe(123));",
'jest.config.mts': `
import type {Config} from '@jest/types';
const config: Config.InitialOptions = {displayName: 'ts-mts-object-config', verbose: true};
export default config;
`,
'package.json': '{"type": "commonjs"}',
});
const {configs, globalConfig} = getConfig(path.join(DIR));
expect(configs).toHaveLength(1);
expect(configs[0].displayName?.name).toBe('ts-mts-object-config');
expect(globalConfig.verbose).toBe(true);
},
);
testIfNativeTypeScript(
'with object config exported from MTS file when package.json#type=module',
() => {
writeFiles(DIR, {
'__tests__/dummy.test.js': "test('dummy', () => expect(12).toBe(12));",
'jest.config.mts': `
import type {Config} from '@jest/types';
const config: Config.InitialOptions = {displayName: 'ts-mts-esm-object-config', verbose: true};
export default config;
`,
'package.json': '{"type": "module"}',
});
const {configs, globalConfig} = getConfig(path.join(DIR));
expect(configs).toHaveLength(1);
expect(configs[0].displayName?.name).toBe('ts-mts-esm-object-config');
expect(globalConfig.verbose).toBe(true);
},
);
testIfTsLoader('throws if type errors are encountered', () => {
writeFiles(DIR, {
'__tests__/dummy.test.js': "test('dummy', () => expect(123).toBe(123));",
@@ -0,0 +1,9 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export default {
jestConfig: 'jest.config.mts',
};
@@ -0,0 +1,8 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export default {};
@@ -54,6 +54,15 @@ Object {
}
`;
exports[`init has-jest-config-file-mts ask the user whether to override config or not user answered with "Yes" 1`] = `
Object {
"initial": true,
"message": "It seems that you already have a jest configuration, do you want to override it?",
"name": "continue",
"type": "confirm",
}
`;
exports[`init has-jest-config-file-ts ask the user whether to override config or not user answered with "Yes" 1`] = `
Object {
"initial": true,
+2 -2
View File
@@ -89,13 +89,13 @@ describe('check', () => {
it('raises an exception if config is not a valid JSON string', () => {
expect(() => check(argv({config: 'x:1'}))).toThrow(
'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .cjs, .cts, .json',
'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .mts, .cjs, .cts, .json',
);
});
it('raises an exception if config is not a supported file type', () => {
const message =
'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .cjs, .cts, .json';
'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .mts, .cjs, .cts, .json';
expect(() => check(argv({config: 'jest.configjs'}))).toThrow(message);
expect(() => check(argv({config: 'jest.config.exe'}))).toThrow(message);
@@ -14,6 +14,50 @@ import {onNodeVersions} from '@jest/test-utils';
jest.mock('graceful-fs').mock('jest-util');
describe('readConfigFileAndSetRootDir', () => {
describe('TypeScript ESM file', () => {
test('reads .mts config and sets `rootDir`', async () => {
jest.mocked(requireOrImportModule).mockResolvedValueOnce({notify: true});
const rootDir = path.resolve('some', 'path', 'to');
const config = await readConfigFileAndSetRootDir(
path.join(rootDir, 'jest.config.mts'),
);
expect(config).toEqual({notify: true, rootDir});
});
test('throws a clear error when native import fails, without falling back to ts-node', async () => {
jest
.mocked(requireOrImportModule)
.mockRejectedValueOnce(new Error('Unknown file extension ".mts"'));
const configPath = path.join(
path.resolve('some', 'path', 'to'),
'jest.config.mts',
);
await expect(readConfigFileAndSetRootDir(configPath)).rejects.toThrow(
/jest\.config\.mts requires native TypeScript support/,
);
// loadTSConfigFile reads the file for docblock parsing - it must not be called
expect(fs.readFileSync).not.toHaveBeenCalled();
});
test('throws a clear error when native import fails with a SyntaxError', async () => {
jest
.mocked(requireOrImportModule)
.mockRejectedValueOnce(new SyntaxError('Unexpected token'));
const configPath = path.join(
path.resolve('some', 'path', 'to'),
'jest.config.mts',
);
await expect(readConfigFileAndSetRootDir(configPath)).rejects.toThrow(
/jest\.config\.mts requires native TypeScript support/,
);
expect(fs.readFileSync).not.toHaveBeenCalled();
});
});
describe('JavaScript file', () => {
test('reads config and sets `rootDir`', async () => {
jest.mocked(requireOrImportModule).mockResolvedValueOnce({notify: true});
+2
View File
@@ -13,6 +13,7 @@ export const PACKAGE_JSON = 'package.json';
export const JEST_CONFIG_BASE_NAME = 'jest.config';
export const JEST_CONFIG_EXT_CJS = '.cjs';
export const JEST_CONFIG_EXT_MJS = '.mjs';
export const JEST_CONFIG_EXT_MTS = '.mts';
export const JEST_CONFIG_EXT_JS = '.js';
export const JEST_CONFIG_EXT_TS = '.ts';
export const JEST_CONFIG_EXT_CTS = '.cts';
@@ -21,6 +22,7 @@ export const JEST_CONFIG_EXT_ORDER = Object.freeze([
JEST_CONFIG_EXT_JS,
JEST_CONFIG_EXT_TS,
JEST_CONFIG_EXT_MJS,
JEST_CONFIG_EXT_MTS,
JEST_CONFIG_EXT_CJS,
JEST_CONFIG_EXT_CTS,
JEST_CONFIG_EXT_JSON,
@@ -15,6 +15,7 @@ import {interopRequireDefault, isError, requireOrImportModule} from 'jest-util';
import {
JEST_CONFIG_EXT_CTS,
JEST_CONFIG_EXT_JSON,
JEST_CONFIG_EXT_MTS,
JEST_CONFIG_EXT_TS,
PACKAGE_JSON,
} from './constants';
@@ -25,7 +26,8 @@ interface TsLoader {
type TsLoaderModule = 'ts-node' | 'esbuild-register';
// Read the configuration and set its `rootDir`
// 1. If it's a `package.json` file, we look into its "jest" property
// 2. If it's a `jest.config.ts` file, we use `ts-node` to transpile & require it
// 2. If it's a `jest.config.ts`/`jest.config.cts`/`jest.config.mts` file,
// we use native TypeScript support when possible, otherwise a TS loader.
// 3. For any other file, we just require it. If we receive an 'ERR_REQUIRE_ESM'
// from node, perform a dynamic import instead.
export default async function readConfigFileAndSetRootDir(
@@ -33,18 +35,28 @@ export default async function readConfigFileAndSetRootDir(
): Promise<Config.InitialOptions> {
const isTS =
configPath.endsWith(JEST_CONFIG_EXT_TS) ||
configPath.endsWith(JEST_CONFIG_EXT_MTS) ||
configPath.endsWith(JEST_CONFIG_EXT_CTS);
const isMTS = configPath.endsWith(JEST_CONFIG_EXT_MTS);
const isJSON = configPath.endsWith(JEST_CONFIG_EXT_JSON);
let configObject;
try {
if (isTS) {
// .mts is always ESM, so attempt import-based loading first.
// @ts-expect-error: Type assertion can be removed once @types/node is updated to 23 https://nodejs.org/api/process.html#processfeaturestypescript
if (process.features.typescript) {
if (isMTS || process.features.typescript) {
try {
// Try native node TypeScript support first.
configObject = await requireOrImportModule<any>(configPath);
} catch (requireOrImportModuleError) {
if (isMTS) {
// .mts is always ESM and cannot be loaded via require()/ts-node.
throw new Error(
'jest.config.mts requires native TypeScript support. Ensure you are using Node.js 22.18+ or 23.6+.',
{cause: requireOrImportModuleError},
);
}
if (!(requireOrImportModuleError instanceof SyntaxError)) {
if (!hasTsLoaderExplicitlyConfigured(configPath)) {
throw requireOrImportModuleError;
@@ -226,6 +238,7 @@ async function registerTsLoader(loader: TsLoaderModule): Promise<TsLoader> {
) {
throw new Error(
`Jest: '${loader}' is required for the TypeScript configuration files. Make sure it is installed\nError: ${error.message}`,
{cause: error},
);
}
@@ -52,7 +52,7 @@ export default async function requireOrImportModule<T>(
);
}
try {
if (filePath.endsWith('.mjs')) {
if (filePath.endsWith('.mjs') || filePath.endsWith('.mts')) {
return importModule(filePath, applyInteropRequireDefault);
}