feat(testing): add ability to split jest tests (#22662)

This commit is contained in:
Jason Jean 2024-04-10 14:00:03 -04:00 committed by GitHub
parent 4cd1808e48
commit fd7cf38c20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 440 additions and 636 deletions

View File

@ -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" %}

View File

@ -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

View File

@ -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" %}

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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",

View File

@ -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",
},

View File

@ -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

File diff suppressed because it is too large Load Diff