feat(testing): add ability to split jest tests (#22662)
This commit is contained in:
parent
4cd1808e48
commit
fd7cf38c20
@ -40,6 +40,54 @@ npm add -D @nx/jest
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
#### Configuring @nx/jest/plugin for both E2E and Unit Tests
|
||||
|
||||
While Jest is most often used for unit tests, there are cases where it can be used for e2e tests as well as unit tests
|
||||
within the same workspace. In this case, you can configure the `@nx/jest/plugin` twice for the different cases.
|
||||
|
||||
```json {% fileName="nx.json" %}
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"plugin": "@nx/jest/plugin",
|
||||
"exclude": ["e2e/**/*"],
|
||||
"options": {
|
||||
"targetName": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/jest/plugin",
|
||||
"include": ["e2e/**/*"],
|
||||
"options": {
|
||||
"targetName": "e2e-local",
|
||||
"ciTargetName": "e2e-ci"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Splitting E2E Tests
|
||||
|
||||
If Jest is used to run E2E tests, you can enable [splitting the tasks](/ci/features/split-e2e-tasks) by file to get
|
||||
improved caching, distribution, and retrying flaky tests. Enable this, by providing a `ciTargetName`. This will create a
|
||||
target with that name which can be used in CI to run the tests for each file in a distributed fashion.
|
||||
|
||||
```json {% fileName="nx.json" %}
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"plugin": "@nx/jest/plugin",
|
||||
"include": ["e2e/**/*"],
|
||||
"options": {
|
||||
"targetName": "e2e-local",
|
||||
"ciTargetName": "e2e-ci"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### How @nx/jest Infers Tasks
|
||||
|
||||
{% callout type="note" title="Inferred Tasks" %}
|
||||
|
||||
@ -11,7 +11,7 @@ You could manually address these problems by splitting your e2e tests into small
|
||||
|
||||
## Set up
|
||||
|
||||
To enable automatically split e2e tasks, you need to turn on [inferred tasks](/concepts/inferred-tasks#existing-nx-workspaces) for the [@nx/cypress](/nx-api/cypress) or [@nx/playwright](/nx-api/playwright) plugins. Run this command to set up inferred tasks:
|
||||
To enable automatically split e2e tasks, you need to turn on [inferred tasks](/concepts/inferred-tasks#existing-nx-workspaces) for the [@nx/cypress](/nx-api/cypress), [@nx/playwright](/nx-api/playwright), or [@nx/playwright](/nx-api/jest) plugins. Run this command to set up inferred tasks:
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="Cypress" %}
|
||||
@ -27,6 +27,13 @@ nx add @nx/cypress
|
||||
nx add @nx/playwright
|
||||
```
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="Jest" %}
|
||||
|
||||
```shell {% skipRescope=true %}
|
||||
nx add @nx/jest
|
||||
```
|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
@ -34,10 +41,11 @@ This command will register the appropriate plugin in the `plugins` array of `nx.
|
||||
|
||||
## Manual Configuration
|
||||
|
||||
If you are already using the `@nx/cypress` or `@nx/playwright` plugin, you need to manually add the appropriate configuration to the `plugins` array of `nx.json`. Follow the instructions for the plugin you are using:
|
||||
If you are already using the `@nx/cypress`, `@nx/playwright`, or `@nx/jest` plugin, you need to manually add the appropriate configuration to the `plugins` array of `nx.json`. Follow the instructions for the plugin you are using:
|
||||
|
||||
- [Configure Cypress Task Splitting](/nx-api/cypress#nxcypress-configuration)
|
||||
- [Configure Playwright Task Splitting](/nx-api/playwright#nxplaywright-configuration)
|
||||
- [Configure Jest Task Splitting](/nx-api/jest#splitting-e2e-tests)
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@ -40,6 +40,54 @@ npm add -D @nx/jest
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
#### Configuring @nx/jest/plugin for both E2E and Unit Tests
|
||||
|
||||
While Jest is most often used for unit tests, there are cases where it can be used for e2e tests as well as unit tests
|
||||
within the same workspace. In this case, you can configure the `@nx/jest/plugin` twice for the different cases.
|
||||
|
||||
```json {% fileName="nx.json" %}
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"plugin": "@nx/jest/plugin",
|
||||
"exclude": ["e2e/**/*"],
|
||||
"options": {
|
||||
"targetName": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/jest/plugin",
|
||||
"include": ["e2e/**/*"],
|
||||
"options": {
|
||||
"targetName": "e2e-local",
|
||||
"ciTargetName": "e2e-ci"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Splitting E2E Tests
|
||||
|
||||
If Jest is used to run E2E tests, you can enable [splitting the tasks](/ci/features/split-e2e-tasks) by file to get
|
||||
improved caching, distribution, and retrying flaky tests. Enable this, by providing a `ciTargetName`. This will create a
|
||||
target with that name which can be used in CI to run the tests for each file in a distributed fashion.
|
||||
|
||||
```json {% fileName="nx.json" %}
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"plugin": "@nx/jest/plugin",
|
||||
"include": ["e2e/**/*"],
|
||||
"options": {
|
||||
"targetName": "e2e-local",
|
||||
"ciTargetName": "e2e-ci"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### How @nx/jest Infers Tasks
|
||||
|
||||
{% callout type="note" title="Inferred Tasks" %}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
runCLIAsync,
|
||||
uniq,
|
||||
updateFile,
|
||||
updateJson,
|
||||
} from '@nx/e2e/utils';
|
||||
|
||||
describe('Jest', () => {
|
||||
@ -147,4 +148,19 @@ describe('Jest', () => {
|
||||
'Test Suites: 1 passed, 1 total'
|
||||
);
|
||||
}, 90000);
|
||||
|
||||
it('should be able to run e2e tests split by tasks', async () => {
|
||||
const libName = uniq('lib');
|
||||
runCLI(`generate @nx/js:lib ${libName} --unitTestRunner=jest`);
|
||||
updateJson('nx.json', (json) => {
|
||||
const jestPlugin = json.plugins.find(
|
||||
(plugin) => plugin.plugin === '@nx/jest/plugin'
|
||||
);
|
||||
|
||||
jestPlugin.options.ciTargetName = 'e2e-ci';
|
||||
return json;
|
||||
});
|
||||
|
||||
await runCLIAsync(`e2e-ci ${libName}`);
|
||||
}, 90000);
|
||||
});
|
||||
|
||||
@ -104,7 +104,7 @@ describe('Remix E2E Tests', () => {
|
||||
expect(result).toContain(`Successfully ran target test`);
|
||||
|
||||
const reactResult = runCLI(`test ${reactapp}`);
|
||||
expect(result).toContain(`Successfully ran target test`);
|
||||
expect(reactResult).toContain(`Successfully ran target test`);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
|
||||
@ -206,6 +206,7 @@
|
||||
"jest-environment-jsdom": "29.4.3",
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"jest-resolve": "^29.4.1",
|
||||
"jest-runtime": "^29.4.1",
|
||||
"jest-util": "^29.4.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"js-yaml": "4.1.0",
|
||||
|
||||
@ -24,7 +24,9 @@ describe('@nx/jest/plugin', () => {
|
||||
};
|
||||
|
||||
await tempFs.createFiles({
|
||||
'proj/jest.config.js': '',
|
||||
'proj/jest.config.js': `module.exports = {}`,
|
||||
'proj/src/unit.spec.ts': '',
|
||||
'proj/src/ignore.spec.ts': '',
|
||||
'proj/project.json': '{}',
|
||||
});
|
||||
});
|
||||
@ -50,6 +52,7 @@ describe('@nx/jest/plugin', () => {
|
||||
|
||||
expect(nodes.projects.proj).toMatchInlineSnapshot(`
|
||||
{
|
||||
"metadata": undefined,
|
||||
"root": "proj",
|
||||
"targets": {
|
||||
"test": {
|
||||
@ -64,6 +67,122 @@ describe('@nx/jest/plugin', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"description": "Run Jest Tests",
|
||||
"technologies": [
|
||||
"jest",
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"cwd": "proj",
|
||||
},
|
||||
"outputs": [
|
||||
"{workspaceRoot}/coverage",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should create test-ci targets based on jest.config.ts', async () => {
|
||||
mockJestConfig(
|
||||
{
|
||||
coverageDirectory: '../coverage',
|
||||
testMatch: ['**/*.spec.ts'],
|
||||
testPathIgnorePatterns: ['ignore.spec.ts'],
|
||||
},
|
||||
context
|
||||
);
|
||||
const nodes = await createNodesFunction(
|
||||
'proj/jest.config.js',
|
||||
{
|
||||
targetName: 'test',
|
||||
ciTargetName: 'test-ci',
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
expect(nodes.projects.proj).toMatchInlineSnapshot(`
|
||||
{
|
||||
"metadata": {
|
||||
"targetGroups": {
|
||||
"E2E (CI)": [
|
||||
"test-ci",
|
||||
"test-ci--src/unit.spec.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
"root": "proj",
|
||||
"targets": {
|
||||
"test": {
|
||||
"cache": true,
|
||||
"command": "jest",
|
||||
"inputs": [
|
||||
"default",
|
||||
"^production",
|
||||
{
|
||||
"externalDependencies": [
|
||||
"jest",
|
||||
],
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"description": "Run Jest Tests",
|
||||
"technologies": [
|
||||
"jest",
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"cwd": "proj",
|
||||
},
|
||||
"outputs": [
|
||||
"{workspaceRoot}/coverage",
|
||||
],
|
||||
},
|
||||
"test-ci": {
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"test-ci--src/unit.spec.ts",
|
||||
],
|
||||
"executor": "nx:noop",
|
||||
"inputs": [
|
||||
"default",
|
||||
"^production",
|
||||
{
|
||||
"externalDependencies": [
|
||||
"jest",
|
||||
],
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"description": "Run Jest Tests in CI",
|
||||
"technologies": [
|
||||
"jest",
|
||||
],
|
||||
},
|
||||
"outputs": [
|
||||
"{workspaceRoot}/coverage",
|
||||
],
|
||||
},
|
||||
"test-ci--src/unit.spec.ts": {
|
||||
"cache": true,
|
||||
"command": "jest src/unit.spec.ts",
|
||||
"inputs": [
|
||||
"default",
|
||||
"^production",
|
||||
{
|
||||
"externalDependencies": [
|
||||
"jest",
|
||||
],
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"description": "Run Jest Tests in src/unit.spec.ts",
|
||||
"technologies": [
|
||||
"jest",
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"cwd": "proj",
|
||||
},
|
||||
|
||||
@ -4,13 +4,13 @@ import {
|
||||
CreateNodesContext,
|
||||
joinPathFragments,
|
||||
NxJsonConfiguration,
|
||||
ProjectConfiguration,
|
||||
readJsonFile,
|
||||
TargetConfiguration,
|
||||
writeJsonFile,
|
||||
} from '@nx/devkit';
|
||||
import { dirname, join, relative, resolve } from 'path';
|
||||
import { dirname, join, normalize, relative, resolve } from 'path';
|
||||
|
||||
import { readTargetDefaultsForTarget } from 'nx/src/project-graph/utils/project-configuration-utils';
|
||||
import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs';
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { readConfig } from 'jest-config';
|
||||
@ -22,26 +22,21 @@ import { minimatch } from 'minimatch';
|
||||
|
||||
export interface JestPluginOptions {
|
||||
targetName?: string;
|
||||
ciTargetName?: string;
|
||||
}
|
||||
|
||||
const cachePath = join(projectGraphCacheDirectory, 'jest.hash');
|
||||
const targetsCache = existsSync(cachePath) ? readTargetsCache() : {};
|
||||
|
||||
const calculatedTargets: Record<
|
||||
string,
|
||||
Record<string, TargetConfiguration>
|
||||
> = {};
|
||||
type JestTargets = Awaited<ReturnType<typeof buildJestTargets>>;
|
||||
|
||||
function readTargetsCache(): Record<
|
||||
string,
|
||||
Record<string, TargetConfiguration>
|
||||
> {
|
||||
const calculatedTargets: JestTargets = {};
|
||||
|
||||
function readTargetsCache(): Record<string, JestTargets> {
|
||||
return readJsonFile(cachePath);
|
||||
}
|
||||
|
||||
function writeTargetsToCache(
|
||||
targets: Record<string, Record<string, TargetConfiguration>>
|
||||
) {
|
||||
function writeTargetsToCache(targets: JestTargets) {
|
||||
writeJsonFile(cachePath, targets);
|
||||
}
|
||||
|
||||
@ -96,17 +91,18 @@ export const createNodes: CreateNodes<JestPluginOptions> = [
|
||||
options = normalizeOptions(options);
|
||||
|
||||
const hash = calculateHashForCreateNodes(projectRoot, options, context);
|
||||
const targets =
|
||||
const { targets, metadata } =
|
||||
targetsCache[hash] ??
|
||||
(await buildJestTargets(configFilePath, projectRoot, options, context));
|
||||
|
||||
calculatedTargets[hash] = targets;
|
||||
calculatedTargets[hash] = { targets, metadata };
|
||||
|
||||
return {
|
||||
projects: {
|
||||
[projectRoot]: {
|
||||
root: projectRoot,
|
||||
targets: targets,
|
||||
targets,
|
||||
metadata,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -118,7 +114,7 @@ async function buildJestTargets(
|
||||
projectRoot: string,
|
||||
options: JestPluginOptions,
|
||||
context: CreateNodesContext
|
||||
) {
|
||||
): Promise<Pick<ProjectConfiguration, 'targets' | 'metadata'>> {
|
||||
const config = await readConfig(
|
||||
{
|
||||
_: [],
|
||||
@ -127,12 +123,6 @@ async function buildJestTargets(
|
||||
resolve(context.workspaceRoot, configFilePath)
|
||||
);
|
||||
|
||||
const targetDefaults = readTargetDefaultsForTarget(
|
||||
options.targetName,
|
||||
context.nxJsonConfiguration.targetDefaults,
|
||||
'nx:run-commands'
|
||||
);
|
||||
|
||||
const namedInputs = getNamedInputs(projectRoot, context);
|
||||
|
||||
const targets: Record<string, TargetConfiguration> = {};
|
||||
@ -142,19 +132,84 @@ async function buildJestTargets(
|
||||
options: {
|
||||
cwd: projectRoot,
|
||||
},
|
||||
metadata: {
|
||||
technologies: ['jest'],
|
||||
description: 'Run Jest Tests',
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetDefaults?.cache) {
|
||||
target.cache = true;
|
||||
const cache = (target.cache = true);
|
||||
const inputs = (target.inputs = getInputs(namedInputs));
|
||||
const outputs = (target.outputs = getOutputs(projectRoot, config, context));
|
||||
|
||||
let metadata: ProjectConfiguration['metadata'];
|
||||
if (options?.ciTargetName) {
|
||||
// Resolve the version of `jest-runtime` that `jest` is using.
|
||||
const jestPath = require.resolve('jest');
|
||||
const jest = require(jestPath) as typeof import('jest');
|
||||
// nx-ignore-next-line
|
||||
const { default: Runtime } = require(require.resolve('jest-runtime', {
|
||||
paths: [dirname(jestPath)],
|
||||
// nx-ignore-next-line
|
||||
})) as typeof import('jest-runtime');
|
||||
|
||||
const context = await Runtime.createContext(config.projectConfig, {
|
||||
maxWorkers: 1,
|
||||
watchman: false,
|
||||
});
|
||||
|
||||
const source = new jest.SearchSource(context);
|
||||
|
||||
const specs = await source.getTestPaths(config.globalConfig);
|
||||
|
||||
const testPaths = new Set(specs.tests.map(({ path }) => path));
|
||||
|
||||
if (testPaths.size > 0) {
|
||||
const groupName = 'E2E (CI)';
|
||||
const targetGroup = [];
|
||||
metadata = {
|
||||
targetGroups: {
|
||||
[groupName]: targetGroup,
|
||||
},
|
||||
};
|
||||
const dependsOn: string[] = [];
|
||||
|
||||
targets[options.ciTargetName] = {
|
||||
executor: 'nx:noop',
|
||||
cache: true,
|
||||
inputs,
|
||||
outputs,
|
||||
dependsOn,
|
||||
metadata: {
|
||||
technologies: ['jest'],
|
||||
description: 'Run Jest Tests in CI',
|
||||
},
|
||||
};
|
||||
targetGroup.push(options.ciTargetName);
|
||||
|
||||
for (const testPath of testPaths) {
|
||||
const relativePath = normalize(relative(projectRoot, testPath));
|
||||
const targetName = `${options.ciTargetName}--${relativePath}`;
|
||||
dependsOn.push(targetName);
|
||||
targets[targetName] = {
|
||||
command: `jest ${relativePath}`,
|
||||
cache,
|
||||
inputs,
|
||||
outputs,
|
||||
options: {
|
||||
cwd: projectRoot,
|
||||
},
|
||||
metadata: {
|
||||
technologies: ['jest'],
|
||||
description: `Run Jest Tests in ${relativePath}`,
|
||||
},
|
||||
};
|
||||
targetGroup.push(targetName);
|
||||
}
|
||||
if (!targetDefaults?.inputs) {
|
||||
target.inputs = getInputs(namedInputs);
|
||||
}
|
||||
if (!targetDefaults?.outputs) {
|
||||
target.outputs = getOutputs(projectRoot, config, context);
|
||||
}
|
||||
|
||||
return targets;
|
||||
return { targets, metadata };
|
||||
}
|
||||
|
||||
function getInputs(
|
||||
@ -200,6 +255,7 @@ function getOutputs(
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
function normalizeOptions(options: JestPluginOptions): JestPluginOptions {
|
||||
options ??= {};
|
||||
options.targetName ??= 'test';
|
||||
|
||||
708
pnpm-lock.yaml
generated
708
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user