Skip to content

Commit 5d6726d

Browse files
alexander-turnerclaude
authored andcommitted
feat: add --collect-tests flag to discover tests without executing them
https://claude.ai/code/session_01QZMfWaN6e1W1ExV78YLUrw
1 parent efb59c2 commit 5d6726d

File tree

18 files changed

+623
-5
lines changed

18 files changed

+623
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## main
22

3+
### Features
4+
5+
- `[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))
6+
37
## 30.3.0
48

59
### Features

e2e/__tests__/collectTests.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import runJest from '../runJest';
9+
10+
describe('jest --collect-tests', () => {
11+
test('lists test names without executing test bodies', () => {
12+
const {exitCode, stdout} = runJest('each', [
13+
'--collect-tests',
14+
'--testPathPatterns=success',
15+
]);
16+
17+
expect(exitCode).toBe(0);
18+
expect(stdout).toContain("The word red contains the letter 'e'");
19+
expect(stdout).toContain('passes one row expected true == true');
20+
expect(stdout).toContain('passes all rows expected true == true');
21+
expect(stdout).toContain('success.test.js');
22+
});
23+
24+
test('produces valid JSON with --json', () => {
25+
const {exitCode, stdout} = runJest('each', [
26+
'--collect-tests',
27+
'--json',
28+
'--testPathPatterns=success',
29+
]);
30+
31+
expect(exitCode).toBe(0);
32+
const json = JSON.parse(stdout);
33+
expect(json.success).toBe(true);
34+
expect(json.numTotalTestSuites).toBe(1);
35+
expect(json.numPendingTests).toBeGreaterThan(0);
36+
37+
const testFile = json.testResults[0];
38+
expect(testFile.name).toContain('success.test.js');
39+
for (const assertion of testFile.assertionResults) {
40+
expect(assertion.status).toBe('pending');
41+
}
42+
});
43+
44+
test('does not execute tests (failing tests still exit 0)', () => {
45+
const {exitCode, stdout} = runJest('each', [
46+
'--collect-tests',
47+
'--testPathPatterns=failure',
48+
]);
49+
50+
expect(exitCode).toBe(0);
51+
expect(stdout).toContain('failure.test.js');
52+
expect(stdout).toContain('fails');
53+
});
54+
55+
test('filters correctly with --testNamePattern', () => {
56+
const {exitCode, stdout} = runJest('each', [
57+
'--collect-tests',
58+
'--testPathPatterns=success',
59+
'--testNamePattern=one row',
60+
]);
61+
62+
expect(exitCode).toBe(0);
63+
expect(stdout).toContain('passes one row expected');
64+
expect(stdout).not.toContain("The word red contains the letter 'e'");
65+
});
66+
67+
test('exits 0 even when no tests match', () => {
68+
const {exitCode, stdout} = runJest('each', [
69+
'--collect-tests',
70+
'--testPathPatterns=nonexistent',
71+
]);
72+
73+
expect(exitCode).toBe(0);
74+
expect(stdout).toContain('No tests found');
75+
});
76+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {beforeEach, describe, expect, it, jest} from '@jest/globals';
9+
import {makeProjectConfig} from '@jest/test-utils';
10+
import type {Circus} from '@jest/types';
11+
import {getState as getRunnerState, resetState} from '../../state';
12+
import {makeDescribe, makeTest} from '../../utils';
13+
import {collectTestsWithoutRunning} from '../jestAdapterInit';
14+
15+
jest.mock('../../state', () => {
16+
const actual =
17+
jest.requireActual<typeof import('../../state')>('../../state');
18+
return {...actual, dispatch: jest.fn<typeof actual.dispatch>()};
19+
});
20+
21+
beforeEach(() => {
22+
resetState();
23+
});
24+
25+
const addTest = (name: string, parent: Circus.DescribeBlock) => {
26+
const test = makeTest(
27+
() => {},
28+
undefined,
29+
false,
30+
name,
31+
parent,
32+
undefined,
33+
new Error(),
34+
false,
35+
);
36+
parent.children.push(test);
37+
};
38+
39+
const collect = (testPath = '/test.js') =>
40+
collectTestsWithoutRunning({config: makeProjectConfig(), testPath});
41+
42+
describe('collectTestsWithoutRunning', () => {
43+
it('collects flat tests with pending status', async () => {
44+
const root = getRunnerState().rootDescribeBlock;
45+
addTest('test one', root);
46+
addTest('test two', root);
47+
48+
const result = await collect();
49+
50+
expect(result.testResults.map(r => r.title)).toEqual([
51+
'test one',
52+
'test two',
53+
]);
54+
expect(result.testResults[0].status).toBe('pending');
55+
expect(result.numPendingTests).toBe(2);
56+
});
57+
58+
it('collects nested tests with correct ancestor titles', async () => {
59+
const root = getRunnerState().rootDescribeBlock;
60+
const outer = makeDescribe('outer', root);
61+
root.children.push(outer);
62+
const inner = makeDescribe('inner', outer);
63+
outer.children.push(inner);
64+
addTest('deep test', inner);
65+
66+
const result = await collect();
67+
68+
expect(result.testResults[0].ancestorTitles).toEqual(['outer', 'inner']);
69+
expect(result.testResults[0].fullName).toBe('outer inner deep test');
70+
});
71+
72+
it('preserves source order across describe blocks', async () => {
73+
const root = getRunnerState().rootDescribeBlock;
74+
const a = makeDescribe('A', root);
75+
root.children.push(a);
76+
addTest('first', a);
77+
const b = makeDescribe('B', root);
78+
root.children.push(b);
79+
addTest('second', b);
80+
81+
const result = await collect();
82+
83+
expect(result.testResults.map(r => r.title)).toEqual(['first', 'second']);
84+
});
85+
86+
it('returns empty results when no tests exist', async () => {
87+
const result = await collect();
88+
expect(result.testResults).toHaveLength(0);
89+
});
90+
91+
it('filters tests by testNamePattern from runner state', async () => {
92+
const state = getRunnerState();
93+
state.testNamePattern = /one/;
94+
addTest('test one', state.rootDescribeBlock);
95+
addTest('test two', state.rootDescribeBlock);
96+
97+
const result = await collect();
98+
99+
expect(result.testResults.map(r => r.title)).toEqual(['test one']);
100+
});
101+
});

packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ const jestAdapter = async (
2222
testPath: string,
2323
sendMessageToJest?: TestFileEvent,
2424
): Promise<TestResult> => {
25-
const {initialize, runAndTransformResultsToJestFormat} =
26-
runtime.requireInternalModule<typeof import('./jestAdapterInit')>(
27-
FRAMEWORK_INITIALIZER,
28-
);
25+
const {
26+
collectTestsWithoutRunning,
27+
initialize,
28+
runAndTransformResultsToJestFormat,
29+
} = runtime.requireInternalModule<typeof import('./jestAdapterInit')>(
30+
FRAMEWORK_INITIALIZER,
31+
);
2932

3033
const {globals, snapshotState} = await initialize({
3134
config,
@@ -96,6 +99,14 @@ const jestAdapter = async (
9699
runtime.requireModule(testPath);
97100
}
98101

102+
if (globalConfig.collectTests) {
103+
const results = await collectTestsWithoutRunning({
104+
config,
105+
testPath,
106+
});
107+
return deepCyclicCopy(results, {keepPrototype: false});
108+
}
109+
99110
const setupAfterEnvPerfStats = {
100111
setupAfterEnvEnd,
101112
setupAfterEnvStart,

packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,62 @@ export const initialize = async ({
140140
return {globals: globalsObject, snapshotState};
141141
};
142142

143+
export const collectTestsWithoutRunning = async ({
144+
config,
145+
testPath,
146+
}: {
147+
config: Config.ProjectConfig;
148+
testPath: string;
149+
}): Promise<TestResult> => {
150+
const {rootDescribeBlock, testNamePattern} = getRunnerState();
151+
152+
const assertionResults: Array<AssertionResult> = [];
153+
154+
const walk = (
155+
block: Circus.DescribeBlock,
156+
ancestors: Array<string>,
157+
): void => {
158+
for (const child of block.children) {
159+
if (child.type === 'describeBlock') {
160+
walk(child, [...ancestors, child.name]);
161+
continue;
162+
}
163+
164+
if (testNamePattern && !testNamePattern.test(getTestID(child))) {
165+
continue;
166+
}
167+
168+
const title = child.name;
169+
assertionResults.push({
170+
ancestorTitles: [...ancestors],
171+
duration: null,
172+
failing: false,
173+
failureDetails: [],
174+
failureMessages: [],
175+
fullName: [...ancestors, title].join(' '),
176+
invocations: 0,
177+
location: null,
178+
numPassingAsserts: 0,
179+
retryReasons: [],
180+
startAt: null,
181+
status: 'pending' as Status,
182+
title,
183+
});
184+
}
185+
};
186+
walk(rootDescribeBlock, []);
187+
188+
await dispatch({name: 'teardown'});
189+
190+
return {
191+
...createEmptyTestResult(),
192+
displayName: config.displayName,
193+
numPendingTests: assertionResults.length,
194+
testFilePath: testPath,
195+
testResults: assertionResults,
196+
};
197+
};
198+
143199
export const runAndTransformResultsToJestFormat = async ({
144200
config,
145201
globalConfig,

packages/jest-cli/src/args.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ export const options: {[key: string]: Options} = {
172172
requiresArg: true,
173173
type: 'string',
174174
},
175+
collectTests: {
176+
description:
177+
'Discover and report all test cases without executing them. ' +
178+
'Prints a tree of test suites and test names.',
179+
type: 'boolean',
180+
},
175181
color: {
176182
description:
177183
'Forces test results output color highlighting (even if ' +

packages/jest-config/src/ValidConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const initialOptions: Config.InitialOptions = {
2424
clearMocks: false,
2525
collectCoverage: true,
2626
collectCoverageFrom: ['src', '!public'],
27+
collectTests: false,
2728
coverageDirectory: 'coverage',
2829
coveragePathIgnorePatterns: [NODE_MODULES_REGEXP],
2930
coverageProvider: 'v8',

packages/jest-config/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ const groupOptions = (
133133
ci: options.ci,
134134
collectCoverage: options.collectCoverage,
135135
collectCoverageFrom: options.collectCoverageFrom,
136+
collectTests: options.collectTests,
136137
coverageDirectory: options.coverageDirectory,
137138
coverageProvider: options.coverageProvider,
138139
coverageReporters: options.coverageReporters,

packages/jest-config/src/normalize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,7 @@ export default async function normalize(
894894
case 'changedFilesWithAncestor':
895895
case 'clearMocks':
896896
case 'collectCoverage':
897+
case 'collectTests':
897898
case 'coverageProvider':
898899
case 'coverageReporters':
899900
case 'coverageThreshold':
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import type {AssertionResult} from '@jest/test-result';
9+
import {printCollectedTestTree} from '../runJest';
10+
11+
const makeResult = (
12+
title: string,
13+
ancestorTitles: Array<string> = [],
14+
): AssertionResult => ({ancestorTitles, title}) as AssertionResult;
15+
16+
const collectOutput = (fn: (stream: NodeJS.WritableStream) => void): string => {
17+
const chunks: Array<string> = [];
18+
const stream = {write: (s: string) => chunks.push(s) && true};
19+
fn(stream as NodeJS.WritableStream);
20+
return chunks.join('');
21+
};
22+
23+
describe('printCollectedTestTree', () => {
24+
test('prints top-level tests', () => {
25+
const output = collectOutput(stream =>
26+
printCollectedTestTree([makeResult('standalone')], stream),
27+
);
28+
expect(output).toContain(' standalone\n');
29+
});
30+
31+
test('prints tests grouped by describe blocks', () => {
32+
const output = collectOutput(stream =>
33+
printCollectedTestTree(
34+
[makeResult('test a', ['suite']), makeResult('test b', ['suite'])],
35+
stream,
36+
),
37+
);
38+
expect(output).toContain('suite\n');
39+
expect(output).toContain(' test a\n');
40+
expect(output).toContain(' test b\n');
41+
});
42+
43+
test('prints nested describe blocks with indentation', () => {
44+
const output = collectOutput(stream =>
45+
printCollectedTestTree([makeResult('deep', ['outer', 'inner'])], stream),
46+
);
47+
expect(output).toContain('outer\n');
48+
expect(output).toContain(' inner\n');
49+
expect(output).toContain(' deep\n');
50+
});
51+
52+
test('handles empty results', () => {
53+
const output = collectOutput(stream => printCollectedTestTree([], stream));
54+
expect(output).toBe('');
55+
});
56+
});

0 commit comments

Comments
 (0)