From 4ecf91ccb26f012f76b4d3a758f5b205590d5488 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Fri, 30 Jun 2023 16:49:33 +0300 Subject: [PATCH] feat: add support for snapshot matchers in concurrent tests (#14139) --- CHANGELOG.md | 1 + e2e/__tests__/snapshot-concurrent.test.ts | 16 ++++++ .../__snapshots__/works.test.js.snap | 29 +++++++++++ .../__tests__/works.test.js | 50 +++++++++++++++++++ e2e/snapshot-concurrent/package.json | 5 ++ packages/expect/package.json | 1 + packages/expect/src/types.ts | 2 + packages/jest-circus/src/run.ts | 50 ++++++++++++------- packages/jest-snapshot/src/index.ts | 4 +- yarn.lock | 1 + 10 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 e2e/__tests__/snapshot-concurrent.test.ts create mode 100644 e2e/snapshot-concurrent/__tests__/__snapshots__/works.test.js.snap create mode 100644 e2e/snapshot-concurrent/__tests__/works.test.js create mode 100644 e2e/snapshot-concurrent/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e61c137a745..833f5b251e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[jest-circus, jest-snapshot]` Add support for snapshot matchers in concurrent tests ([#14139](https://github.com/jestjs/jest/pull/14139)) - `[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)) - `[jest-core, jest-circus, jest-reporter, jest-runner]` Added support for reporting about start individual test cases using jest-circus ([#14174](https://github.com/jestjs/jest/pull/14174)) 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..9564f5b6664f --- /dev/null +++ b/e2e/snapshot-concurrent/__tests__/__snapshots__/works.test.js.snap @@ -0,0 +1,29 @@ +// 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[`A d 1`] = `"Ad1"`; + +exports[`A d 2`] = `"Ad2"`; + +exports[`B 1`] = `"B1"`; + +exports[`B 2`] = `"B2"`; + +exports[`C 1`] = `"C1"`; + +exports[`C 2`] = `"C2"`; + +exports[`D 1`] = `"D1"`; + +exports[`D 2`] = `"D2"`; diff --git a/e2e/snapshot-concurrent/__tests__/works.test.js b/e2e/snapshot-concurrent/__tests__/works.test.js new file mode 100644 index 000000000000..dd59e404cf88 --- /dev/null +++ b/e2e/snapshot-concurrent/__tests__/works.test.js @@ -0,0 +1,50 @@ +/** + * 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('d', () => { + expect('Ad1').toMatchSnapshot(); + expect('Ad2').toMatchSnapshot(); + }); +}); + +it.concurrent('B', async () => { + await sleep(10); + expect('B1').toMatchSnapshot(); + expect('B2').toMatchSnapshot(); +}); + +it('C', () => { + expect('C1').toMatchSnapshot(); + expect('C2').toMatchSnapshot(); +}); + +it.concurrent('D', async () => { + expect('D1').toMatchSnapshot(); + expect('D2').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 ec07b7d13dfd..ea6d5eb3076f 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( diff --git a/yarn.lock b/yarn.lock index d43292815677..ad64f226706d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9697,6 +9697,7 @@ __metadata: "@jest/expect-utils": "workspace:^" "@jest/test-utils": "workspace:^" "@tsd/typescript": ^5.0.4 + "@types/node": "*" chalk: ^4.0.0 immutable: ^4.0.0 jest-get-type: "workspace:^"