diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0e4a8b3327..d15d0d428f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[jest, jest-config]` Add `defineConfig()` type helper and expose `JestConfig` type ([#12801](https://github.com/facebook/jest/pull/12801)) - `[@jest/reporters]` Improve `GitHubActionsReporter`s annotation format ([#12826](https://github.com/facebook/jest/pull/12826)) ### Fixes diff --git a/docs/Configuration.md b/docs/Configuration.md index 7a8b85413131..5c93e152215f 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -3,29 +3,27 @@ id: configuration title: Configuring Jest --- -Jest's configuration can be defined in the `package.json` file of your project, or through a `jest.config.js`, or `jest.config.ts` file or through the `--config ` option. If you'd like to use your `package.json` to store Jest's config, the `"jest"` key should be used on the top level so Jest will know how to find your settings: +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; -```json -{ - "name": "my-project", - "jest": { - "verbose": true - } -} -``` +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|cjs|mjs|json`. You can use [`--config`](CLI.md#--configpath) flag to pass an explicit path to the file. + +:::note + +Keep in mind that the resulting configuration object must always be JSON-serializable. + +::: -Or through JavaScript: +The configuration file should simply export an object or a function returning an object: ```js title="jest.config.js" -// Sync object -/** @type {import('@jest/types').Config.InitialOptions} */ -const config = { +module.exports = { verbose: true, }; -module.exports = config; - -// Or async function +// or async function module.exports = async () => { return { verbose: true, @@ -33,48 +31,82 @@ module.exports = async () => { }; ``` -Or through TypeScript (if `ts-node` is installed): +Optionally you can use `defineConfig()` helper for type definitions and autocompletion: + + + + +```js title="jest.config.js" +const {defineConfig} = require('jest'); + +module.exports = defineConfig({ + verbose: true, +}); + +// or async function +module.exports = defineConfig(async () => { + const verbose = await asyncGetVerbose(); + + return {verbose}; +}); +``` + + + + ```ts title="jest.config.ts" -import type {Config} from '@jest/types'; +import {JestConfig, defineConfig} from 'jest'; -// Sync object -const config: Config.InitialOptions = { +export default defineConfig({ verbose: true, -}; -export default config; +}); -// Or async function -export default async (): Promise => { - return { - verbose: true, - }; -}; +// or async function +export default defineConfig(async () => { + const verbose: JestConfig['verbose'] = await asyncGetVerbose(); + + return {verbose}; +}); ``` -Please keep in mind that the resulting configuration must be JSON-serializable. + + + +:::tip -When using the `--config` option, the JSON file must not contain a "jest" key: +Jest requires [`ts-node`](https://github.com/TypeStrong/ts-node) to be able to read TypeScript configuration files. Make sure it is installed in your project. -```json +::: + +The configuration can be stored in a JSON file as a plain object: + +```json title="jest.config.json" { "bail": 1, "verbose": true } ``` -## Options +Alternatively Jest's configuration can be defined through the `"jest"` key in the `package.json` of your project: -These options let you control Jest's behavior in your `package.json` file. The Jest philosophy is to work great by default, but sometimes you just need more configuration power. +```json title="package.json" +{ + "name": "my-project", + "jest": { + "verbose": true + } +} +``` -### Defaults +## Options You can retrieve Jest's default options to expand them if needed: ```js title="jest.config.js" const {defaults} = require('jest-config'); + module.exports = { - // ... moduleFileExtensions: [...defaults.moduleFileExtensions, 'ts', 'tsx'], // ... }; diff --git a/e2e/__tests__/defineConfig.test.ts b/e2e/__tests__/defineConfig.test.ts new file mode 100644 index 000000000000..a461d1c384c0 --- /dev/null +++ b/e2e/__tests__/defineConfig.test.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as path from 'path'; +import {onNodeVersions} from '@jest/test-utils'; +import {cleanup, writeFiles} from '../Utils'; +import {getConfig} from '../runJest'; + +const DIR = path.resolve(__dirname, '../define-config'); + +beforeEach(() => cleanup(DIR)); +afterAll(() => cleanup(DIR)); + +test('works with object config exported from CJS file', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(123).toBe(123));", + 'jest.config.js': ` + const {defineConfig} = require('jest'); + module.exports = defineConfig({displayName: 'cjs-object-config', verbose: true}); + `, + 'package.json': '{}', + }); + + const {configs, globalConfig} = getConfig(path.join(DIR)); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName?.name).toBe('cjs-object-config'); + expect(globalConfig.verbose).toBe(true); +}); + +test('works with function config exported from CJS file', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(123).toBe(123));", + 'jest.config.js': ` + const {defineConfig} = require('jest'); + async function getVerbose() {return true;} + module.exports = defineConfig(async () => { + const verbose = await getVerbose(); + return {displayName: 'cjs-async-function-config', verbose }; + }); + `, + 'package.json': '{}', + }); + + const {configs, globalConfig} = getConfig(path.join(DIR)); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName?.name).toBe('cjs-async-function-config'); + expect(globalConfig.verbose).toBe(true); +}); + +// The versions where vm.Module exists and commonjs with "exports" is not broken +onNodeVersions('>=12.16.0', () => { + test('works with object config exported from ESM file', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(123).toBe(123));", + 'jest.config.js': ` + import jest from 'jest'; + export default jest.defineConfig({displayName: 'esm-object-config', verbose: true}); + `, + 'package.json': '{"type": "module"}', + }); + + const {configs, globalConfig} = getConfig(path.join(DIR)); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName?.name).toBe('esm-object-config'); + expect(globalConfig.verbose).toBe(true); + }); + + test('works with function config exported from ESM file', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(123).toBe(123));", + 'jest.config.js': ` + import jest from 'jest'; + async function getVerbose() {return true;} + export default jest.defineConfig(async () => { + const verbose = await getVerbose(); + return {displayName: 'esm-async-function-config', verbose}; + }); + `, + 'package.json': '{"type": "module"}', + }); + + const {configs, globalConfig} = getConfig(path.join(DIR)); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName?.name).toBe('esm-async-function-config'); + expect(globalConfig.verbose).toBe(true); + }); +}); diff --git a/e2e/__tests__/tsIntegration.test.ts b/e2e/__tests__/tsIntegration.test.ts index 7ae2637f256c..39ae5aa02f82 100644 --- a/e2e/__tests__/tsIntegration.test.ts +++ b/e2e/__tests__/tsIntegration.test.ts @@ -174,3 +174,130 @@ describe('when `Config` type is imported from "@jest/types"', () => { }); }); }); + +describe('when `defineConfig` is imported from "jest"', () => { + test('with object config exported from TS file', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(123).toBe(123));", + 'jest.config.ts': ` + import {defineConfig} from 'jest'; + export default defineConfig({ + displayName: 'ts-object-config', + verbose: true, + }); + `, + 'package.json': '{}', + }); + + const {configs, globalConfig} = getConfig(path.join(DIR)); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName?.name).toBe('ts-object-config'); + expect(globalConfig.verbose).toBe(true); + }); + + test('with function config exported from TS file', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(123).toBe(123));", + 'jest.config.ts': ` + import {JestConfig, defineConfig} from 'jest'; + async function getVerbose() {return true;} + export default defineConfig(async () => { + const verbose: JestConfig['verbose'] = await getVerbose(); + return {displayName: 'ts-async-function-config', verbose}; + }); + `, + 'package.json': '{}', + }); + + const {configs, globalConfig} = getConfig(path.join(DIR)); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName?.name).toBe('ts-async-function-config'); + expect(globalConfig.verbose).toBe(true); + }); + + test('throws if type errors are encountered', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(123).toBe(123));", + 'jest.config.ts': ` + import {defineConfig} from 'jest'; + export default defineConfig({ + fakeTimers: 'all' + }); + `, + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR); + + expect(stderr).toMatch( + "jest.config.ts(3,3): error TS2322: Type 'string' is not assignable to type 'FakeTimers | undefined'.", + ); + expect(exitCode).toBe(1); + }); + + // The versions where vm.Module exists and commonjs with "exports" is not broken + onNodeVersions('>=12.16.0', () => { + test('works with object config exported from TS file when package.json#type=module', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(12).toBe(12));", + 'jest.config.ts': ` + import {defineConfig} from 'jest'; + export default defineConfig({ + displayName: 'ts-esm-object-config', + verbose: true, + }); + `, + 'package.json': '{"type": "module"}', + }); + + const {configs, globalConfig} = getConfig(path.join(DIR)); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName?.name).toBe('ts-esm-object-config'); + expect(globalConfig.verbose).toBe(true); + }); + + test('works with function config exported from TS file when package.json#type=module', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(12).toBe(12));", + 'jest.config.ts': ` + import {JestConfig, defineConfig} from 'jest'; + async function getVerbose() {return true;} + export default defineConfig(async () => { + const verbose: JestConfig['verbose'] = await getVerbose(); + return {displayName: 'ts-esm-async-function-config', verbose}; + }); + `, + 'package.json': '{"type": "module"}', + }); + + const {configs, globalConfig} = getConfig(path.join(DIR)); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName?.name).toBe('ts-esm-async-function-config'); + expect(globalConfig.verbose).toBe(true); + }); + + test('throws if type errors are encountered when package.json#type=module', () => { + writeFiles(DIR, { + '__tests__/dummy.test.js': "test('dummy', () => expect(12).toBe(12));", + 'jest.config.ts': ` + import {defineConfig} from 'jest'; + export default defineConfig({ + fakeTimers: 'all' + }); + `, + 'package.json': '{"type": "module"}', + }); + + const {stderr, exitCode} = runJest(DIR); + + expect(stderr).toMatch( + "jest.config.ts(3,3): error TS2322: Type 'string' is not assignable to type 'FakeTimers | undefined'.", + ); + expect(exitCode).toBe(1); + }); + }); +}); diff --git a/packages/jest-config/__typetests__/jest-config.test.ts b/packages/jest-config/__typetests__/jest-config.test.ts new file mode 100644 index 000000000000..60d463fa7bcf --- /dev/null +++ b/packages/jest-config/__typetests__/jest-config.test.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {expectAssignable, expectError, expectType} from 'tsd-lite'; +import type {Config} from '@jest/types'; +import {JestConfig, defaults, defineConfig} from 'jest-config'; + +// defineConfig() + +expectType>(defineConfig({})); +expectType>(defineConfig({...defaults})); +expectType>(defineConfig({verbose: true})); + +expectType>(defineConfig(() => ({}))); +expectType>(defineConfig(() => ({...defaults}))); +expectType>( + defineConfig(() => ({verbose: true})), +); + +expectType>(defineConfig(async () => ({}))); +expectType>( + defineConfig(async () => ({...defaults})), +); +expectType>( + defineConfig(async () => ({verbose: true})), +); + +expectError(defineConfig()); +expectError(defineConfig(() => {})); +expectError(defineConfig(async () => {})); +expectError(defineConfig({fakeTimers: true})); + +// JestConfig + +expectAssignable({} as JestConfig); diff --git a/packages/jest-config/__typetests__/tsconfig.json b/packages/jest-config/__typetests__/tsconfig.json new file mode 100644 index 000000000000..165ba1343021 --- /dev/null +++ b/packages/jest-config/__typetests__/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "skipLibCheck": true, + + "types": [] + }, + "include": ["./**/*"] +} diff --git a/packages/jest-config/package.json b/packages/jest-config/package.json index 426ba53a87c4..321f6309b967 100644 --- a/packages/jest-config/package.json +++ b/packages/jest-config/package.json @@ -53,11 +53,13 @@ "strip-json-comments": "^3.1.1" }, "devDependencies": { + "@tsd/typescript": "~4.6.2", "@types/glob": "^7.1.1", "@types/graceful-fs": "^4.1.3", "@types/micromatch": "^4.0.1", "semver": "^7.3.5", "ts-node": "^10.5.0", + "tsd-lite": "^0.5.1", "typescript": "^4.6.2" }, "engines": { diff --git a/packages/jest-config/src/defineConfig.ts b/packages/jest-config/src/defineConfig.ts new file mode 100644 index 000000000000..4f9a2d7ac36b --- /dev/null +++ b/packages/jest-config/src/defineConfig.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Config} from '@jest/types'; + +export type JestConfig = Config.InitialOptions; + +/** + * Type helper that provides the type definitions for your Jest configuration. + */ +export default async function defineConfig( + jestConfig: JestConfig | (() => JestConfig) | (() => Promise), +): Promise { + if (typeof jestConfig === 'function') { + return jestConfig(); + } + return jestConfig; +} diff --git a/packages/jest-config/src/index.ts b/packages/jest-config/src/index.ts index 5728f473e5cc..8f7aeeae7479 100644 --- a/packages/jest-config/src/index.ts +++ b/packages/jest-config/src/index.ts @@ -21,9 +21,12 @@ export {default as normalize} from './normalize'; export {default as deprecationEntries} from './Deprecated'; export {replaceRootDirInPath} from './utils'; export {default as defaults} from './Defaults'; +export {default as defineConfig} from './defineConfig'; export {default as descriptions} from './Descriptions'; export {constants}; +export type {JestConfig} from './defineConfig'; + type ReadConfig = { configPath: string | null | undefined; globalConfig: Config.GlobalConfig; diff --git a/packages/jest/package.json b/packages/jest/package.json index 0d7b32ecd020..b89d5c92b67a 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -15,7 +15,8 @@ "dependencies": { "@jest/core": "^28.1.0", "import-local": "^3.0.2", - "jest-cli": "^28.1.0" + "jest-cli": "^28.1.0", + "jest-config": "^28.1.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts index d101fd921a01..df992246bb05 100644 --- a/packages/jest/src/index.ts +++ b/packages/jest/src/index.ts @@ -12,4 +12,7 @@ export { runCLI, } from '@jest/core'; +export {defineConfig} from 'jest-config'; +export type {JestConfig} from 'jest-config'; + export {run} from 'jest-cli'; diff --git a/packages/jest/tsconfig.json b/packages/jest/tsconfig.json index 21169670d305..c7df21c88e63 100644 --- a/packages/jest/tsconfig.json +++ b/packages/jest/tsconfig.json @@ -5,5 +5,9 @@ "outDir": "build" }, "include": ["./src/**/*"], - "references": [{"path": "../jest-cli"}, {"path": "../jest-core"}] + "references": [ + {"path": "../jest-cli"}, + {"path": "../jest-config"}, + {"path": "../jest-core"} + ] } diff --git a/yarn.lock b/yarn.lock index 050470a7b40c..b1598d19fd11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13075,6 +13075,7 @@ __metadata: "@babel/core": ^7.11.6 "@jest/test-sequencer": ^28.1.0 "@jest/types": ^28.1.0 + "@tsd/typescript": ~4.6.2 "@types/glob": ^7.1.1 "@types/graceful-fs": ^4.1.3 "@types/micromatch": ^4.0.1 @@ -13099,6 +13100,7 @@ __metadata: slash: ^3.0.0 strip-json-comments: ^3.1.1 ts-node: ^10.5.0 + tsd-lite: ^0.5.1 typescript: ^4.6.2 peerDependencies: "@types/node": "*" @@ -13779,6 +13781,7 @@ __metadata: "@jest/core": ^28.1.0 import-local: ^3.0.2 jest-cli: ^28.1.0 + jest-config: ^28.1.0 peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: