diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f6424a496c5..83c68accd654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[jest-circus, jest-snapshot]` Add support for snapshot matchers in concurrent tests - `[jest-cli]` Include type definitions to generated config files ([#14078](https://github.com/facebook/jest/pull/14078)) - `[jest-snapshot]` Support arrays as property matchers ([#14025](https://github.com/facebook/jest/pull/14025)) diff --git a/e2e/__tests__/snapshot-concurrent.test.ts b/e2e/__tests__/snapshot-concurrent.test.ts new file mode 100644 index 000000000000..7b860c97ea97 --- /dev/null +++ b/e2e/__tests__/snapshot-concurrent.test.ts @@ -0,0 +1,16 @@ +/** + * 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. + */ + +import {skipSuiteOnJasmine} from '@jest/test-utils'; +import runJest from '../runJest'; + +skipSuiteOnJasmine(); + +test('Snapshots get correct names in concurrent tests', () => { + const result = runJest('snapshot-concurrent', ['--ci']); + expect(result.exitCode).toBe(0); +}); diff --git a/e2e/snapshot-concurrent/__tests__/__snapshots__/works.test.js.snap b/e2e/snapshot-concurrent/__tests__/__snapshots__/works.test.js.snap new file mode 100644 index 000000000000..aa2ad88dc90d --- /dev/null +++ b/e2e/snapshot-concurrent/__tests__/__snapshots__/works.test.js.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`A a 1`] = `"Aa1"`; + +exports[`A a 2`] = `"Aa2"`; + +exports[`A b 1`] = `"Ab1"`; + +exports[`A b 2`] = `"Ab2"`; + +exports[`A c 1`] = `"Ac1"`; + +exports[`A c 2`] = `"Ac2"`; + +exports[`B 1`] = `"B1"`; + +exports[`B 2`] = `"B2"`; + +exports[`C 1`] = `"C1"`; + +exports[`C 2`] = `"C2"`; diff --git a/e2e/snapshot-concurrent/__tests__/works.test.js b/e2e/snapshot-concurrent/__tests__/works.test.js new file mode 100644 index 000000000000..fdc184cc787a --- /dev/null +++ b/e2e/snapshot-concurrent/__tests__/works.test.js @@ -0,0 +1,40 @@ +/** + * 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. + * + */ +'use strict'; + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +describe('A', () => { + it.concurrent('a', async () => { + await sleep(100); + expect('Aa1').toMatchSnapshot(); + expect('Aa2').toMatchSnapshot(); + }); + + it.concurrent('b', async () => { + await sleep(10); + expect('Ab1').toMatchSnapshot(); + expect('Ab2').toMatchSnapshot(); + }); + + it.concurrent('c', async () => { + expect('Ac1').toMatchSnapshot(); + expect('Ac2').toMatchSnapshot(); + }); +}); + +it.concurrent('B', async () => { + await sleep(10); + expect('B1').toMatchSnapshot(); + expect('B2').toMatchSnapshot(); +}); + +it.concurrent('C', async () => { + expect('C1').toMatchSnapshot(); + expect('C2').toMatchSnapshot(); +}); diff --git a/e2e/snapshot-concurrent/package.json b/e2e/snapshot-concurrent/package.json new file mode 100644 index 000000000000..148788b25446 --- /dev/null +++ b/e2e/snapshot-concurrent/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "node" + } +} diff --git a/packages/expect/package.json b/packages/expect/package.json index f197660be2af..5a6c99adbf8b 100644 --- a/packages/expect/package.json +++ b/packages/expect/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@jest/expect-utils": "workspace:^", + "@types/node": "*", "jest-get-type": "workspace:^", "jest-matcher-utils": "workspace:^", "jest-message-util": "workspace:^", diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 3866c15585c6..96e858dad93d 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -6,6 +6,7 @@ * */ +import type {AsyncLocalStorage} from 'async_hooks'; import type {EqualsFunction, Tester} from '@jest/expect-utils'; import type * as jestMatcherUtils from 'jest-matcher-utils'; import {INTERNAL_MATCHER_FLAG} from './jestMatchersObject'; @@ -57,6 +58,7 @@ export interface MatcherUtils { export interface MatcherState { assertionCalls: number; + currentConcurrentTestName?: AsyncLocalStorage; currentTestName?: string; error?: Error; expand?: boolean; diff --git a/packages/jest-circus/src/run.ts b/packages/jest-circus/src/run.ts index 7ccaf2d2e187..96cee63e43a3 100644 --- a/packages/jest-circus/src/run.ts +++ b/packages/jest-circus/src/run.ts @@ -5,7 +5,9 @@ * LICENSE file in the root directory of this source tree. */ +import {AsyncLocalStorage} from 'async_hooks'; import pLimit = require('p-limit'); +import {jestExpect} from '@jest/expect'; import type {Circus} from '@jest/types'; import shuffleArray, {RandomNumberGenerator, rngBuilder} from './shuffleArray'; import {dispatch, getState} from './state'; @@ -19,6 +21,10 @@ import { makeRunResult, } from './utils'; +type ConcurrentTestEntry = Omit & { + fn: Circus.ConcurrentTestFn; +}; + const run = async (): Promise => { const {rootDescribeBlock, seed, randomize} = getState(); const rng = randomize ? rngBuilder(seed) : undefined; @@ -49,20 +55,8 @@ const _runTestsForDescribeBlock = async ( if (isRootBlock) { const concurrentTests = collectConcurrentTests(describeBlock); - const mutex = pLimit(getState().maxConcurrency); - for (const test of concurrentTests) { - try { - const promise = mutex(test.fn); - // Avoid triggering the uncaught promise rejection handler in case the - // test errors before being awaited on. - // eslint-disable-next-line @typescript-eslint/no-empty-function - promise.catch(() => {}); - test.fn = () => promise; - } catch (err) { - test.fn = () => { - throw err; - }; - } + if (concurrentTests.length > 0) { + startTestsConcurrently(concurrentTests); } } @@ -120,7 +114,7 @@ const _runTestsForDescribeBlock = async ( function collectConcurrentTests( describeBlock: Circus.DescribeBlock, -): Array & {fn: Circus.ConcurrentTestFn}> { +): Array { if (describeBlock.mode === 'skip') { return []; } @@ -135,13 +129,33 @@ function collectConcurrentTests( child.mode === 'skip' || (hasFocusedTests && child.mode !== 'only') || (testNamePattern && !testNamePattern.test(getTestID(child))); - return skip - ? [] - : [child as Circus.TestEntry & {fn: Circus.ConcurrentTestFn}]; + return skip ? [] : [child as ConcurrentTestEntry]; } }); } +function startTestsConcurrently(concurrentTests: Array) { + const mutex = pLimit(getState().maxConcurrency); + const testNameStorage = new AsyncLocalStorage(); + jestExpect.setState({currentConcurrentTestName: testNameStorage}); + for (const test of concurrentTests) { + try { + const promise = testNameStorage.run(getTestID(test), () => + mutex(test.fn), + ); + // Avoid triggering the uncaught promise rejection handler in case the + // test fails before being awaited on. + // eslint-disable-next-line @typescript-eslint/no-empty-function + promise.catch(() => {}); + test.fn = () => promise; + } catch (err) { + test.fn = () => { + throw err; + }; + } + } +} + const _runTest = async ( test: Circus.TestEntry, parentSkipped: boolean, diff --git a/packages/jest-snapshot/src/index.ts b/packages/jest-snapshot/src/index.ts index dd310c0773af..ee15429a553a 100644 --- a/packages/jest-snapshot/src/index.ts +++ b/packages/jest-snapshot/src/index.ts @@ -279,7 +279,9 @@ const _toMatchSnapshot = (config: MatchSnapshotConfig) => { context.dontThrow && context.dontThrow(); - const {currentTestName, isNot, snapshotState} = context; + const {currentConcurrentTestName, isNot, snapshotState} = context; + const currentTestName = + currentConcurrentTestName?.getStore() ?? context.currentTestName; if (isNot) { throw new Error(