Skip to content

Isolate expect.assertions state per test.concurrent - #16340

Open
arronKler wants to merge 3 commits into
jestjs:mainfrom
arronKler:fix/14263-concurrent-expect-assertions
Open

Isolate expect.assertions state per test.concurrent#16340
arronKler wants to merge 3 commits into
jestjs:mainfrom
arronKler:fix/14263-concurrent-expect-assertions

Conversation

@arronKler

Copy link
Copy Markdown
Contributor

Summary

expect.assertions(n) and expect.hasAssertions() store their counters on a single global matcher state object. When two test.concurrent cases run at the same time, they increment and check the same assertionCalls field, so a pair of tests that each declare expect.assertions(1) fails with "Expected one assertion to be called but received two assertion calls."

jest-circus already exposes the active test id through currentConcurrentTestName (backed by AsyncLocalStorage). expect.getState() / setState() now keep assertion-related fields on a per-test view keyed by that name, so each concurrent test has its own budget.

Fixes #14263

Test plan

  • Unit tests in packages/expect interleave two synthetic concurrent names and assert that each test only sees its own assertion count / hasAssertions result.
  • Unit tests in jest-circus drive eventHandler test_done for two overlapping concurrent names.
  • e2e fixtures under e2e/circus-concurrent cover passing concurrent + sequential expect.assertions / expect.hasAssertions, and a sibling that exceeds its budget without failing the other test.
  • FORCE_COLOR=1 yarn jest packages/expect/src/__tests__/assertionCounts.test.ts packages/jest-circus/src/legacy-code-todo-rewrite/__tests__/concurrentAssertions.test.ts e2e/__tests__/circusConcurrent.test.ts

expect.assertions and expect.hasAssertions stored their counters on a
single global matcher state object. Concurrent tests therefore shared
assertion counts and failed with "expected one assertion but received
two".

Keep those fields on a per-test view keyed by the circus
AsyncLocalStorage test name so each test.concurrent has its own budget.

Fixes jestjs#14263
@netlify

netlify Bot commented Aug 14, 2026

Copy link
Copy Markdown

Deploy Preview for jestjs ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 54fd8bf
🔍 Latest deploy log https://app.netlify.com/projects/jestjs/deploys/6a7ef2b7bbf84700087bf50d
😎 Deploy Preview https://deploy-preview-16340--jestjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@linux-foundation-easycla

linux-foundation-easycla Bot commented Aug 14, 2026

Copy link
Copy Markdown

CLA Not Signed

@github-actions github-actions Bot added the require-changelog If a PR does requires a changelog entry label Aug 14, 2026
CI Static Checks failed: assigning through IsolatedStateKey was typed as never.
@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

babel-jest

npm i https://pkg.pr.new/babel-jest@16340

babel-plugin-jest-hoist

npm i https://pkg.pr.new/babel-plugin-jest-hoist@16340

babel-preset-jest

npm i https://pkg.pr.new/babel-preset-jest@16340

create-jest

npm i https://pkg.pr.new/create-jest@16340

@jest/diff-sequences

npm i https://pkg.pr.new/@jest/diff-sequences@16340

expect

npm i https://pkg.pr.new/expect@16340

@jest/expect-utils

npm i https://pkg.pr.new/@jest/expect-utils@16340

jest

npm i https://pkg.pr.new/jest@16340

jest-changed-files

npm i https://pkg.pr.new/jest-changed-files@16340

jest-circus

npm i https://pkg.pr.new/jest-circus@16340

jest-cli

npm i https://pkg.pr.new/jest-cli@16340

jest-config

npm i https://pkg.pr.new/jest-config@16340

@jest/console

npm i https://pkg.pr.new/@jest/console@16340

@jest/core

npm i https://pkg.pr.new/@jest/core@16340

@jest/create-cache-key-function

npm i https://pkg.pr.new/@jest/create-cache-key-function@16340

jest-diff

npm i https://pkg.pr.new/jest-diff@16340

jest-docblock

npm i https://pkg.pr.new/jest-docblock@16340

jest-each

npm i https://pkg.pr.new/jest-each@16340

@jest/environment

npm i https://pkg.pr.new/@jest/environment@16340

jest-environment-jsdom

npm i https://pkg.pr.new/jest-environment-jsdom@16340

@jest/environment-jsdom-abstract

npm i https://pkg.pr.new/@jest/environment-jsdom-abstract@16340

jest-environment-node

npm i https://pkg.pr.new/jest-environment-node@16340

@jest/expect

npm i https://pkg.pr.new/@jest/expect@16340

@jest/fake-timers

npm i https://pkg.pr.new/@jest/fake-timers@16340

@jest/get-type

npm i https://pkg.pr.new/@jest/get-type@16340

@jest/globals

npm i https://pkg.pr.new/@jest/globals@16340

jest-haste-map

npm i https://pkg.pr.new/jest-haste-map@16340

jest-jasmine2

npm i https://pkg.pr.new/jest-jasmine2@16340

jest-leak-detector

npm i https://pkg.pr.new/jest-leak-detector@16340

jest-matcher-utils

npm i https://pkg.pr.new/jest-matcher-utils@16340

jest-message-util

npm i https://pkg.pr.new/jest-message-util@16340

jest-mock

npm i https://pkg.pr.new/jest-mock@16340

@jest/pattern

npm i https://pkg.pr.new/@jest/pattern@16340

jest-phabricator

npm i https://pkg.pr.new/jest-phabricator@16340

jest-regex-util

npm i https://pkg.pr.new/jest-regex-util@16340

@jest/reporters

npm i https://pkg.pr.new/@jest/reporters@16340

jest-resolve

npm i https://pkg.pr.new/jest-resolve@16340

jest-resolve-dependencies

npm i https://pkg.pr.new/jest-resolve-dependencies@16340

jest-runner

npm i https://pkg.pr.new/jest-runner@16340

jest-runtime

npm i https://pkg.pr.new/jest-runtime@16340

@jest/schemas

npm i https://pkg.pr.new/@jest/schemas@16340

jest-snapshot

npm i https://pkg.pr.new/jest-snapshot@16340

@jest/snapshot-utils

npm i https://pkg.pr.new/@jest/snapshot-utils@16340

@jest/source-map

npm i https://pkg.pr.new/@jest/source-map@16340

@jest/test-result

npm i https://pkg.pr.new/@jest/test-result@16340

@jest/test-sequencer

npm i https://pkg.pr.new/@jest/test-sequencer@16340

@jest/transform

npm i https://pkg.pr.new/@jest/transform@16340

@jest/types

npm i https://pkg.pr.new/@jest/types@16340

jest-util

npm i https://pkg.pr.new/jest-util@16340

jest-validate

npm i https://pkg.pr.new/jest-validate@16340

jest-watcher

npm i https://pkg.pr.new/jest-watcher@16340

jest-worker

npm i https://pkg.pr.new/jest-worker@16340

pretty-format

npm i https://pkg.pr.new/pretty-format@16340

commit: 54fd8bf

@arronKler

Copy link
Copy Markdown
Contributor Author

recheck

@soltonigiri soltonigiri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this. I used b654d7e as the main baseline and 54fd8bf as the PR head. The original reproduction with distinct titles fails on the baseline and passes on the PR head. The PR’s focused command passes all 23 tests. I also ran build:js, build:ts, typecheck:tests, ESLint, Prettier, the changelog check, and git diff --check successfully.

I found one correctness gap that I think needs to be addressed before merge: the isolated matcher state is keyed by the full test-name string, so concurrent tests with the same full title still share assertion state. The reproduction and details are inline.


const getIsolatedStateView = (testName: string): MatcherState => {
const store = getMatchersObject();
const existing = store.isolatedMatcherStates.get(testName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs a collision-free execution identity rather than the full test-name string.

testName ultimately comes from testNameStorage.run(getTestID(child), ...), and getTestID() is the joined title path. Jest’s runtime does not reject duplicate titles, so two concurrent test entries with the same full title use the same isolatedMatcherStates entry.

On 54fd8bf, I reproduced this with two test.concurrent('duplicate name', ...) cases. A barrier ensures that each case calls expect.assertions(1) and makes exactly one assertion before either case completes. One case then fails with:

Expected one assertion to be called but received two assertion calls.

Changing only one title makes the fixture pass.

Could we carry a separate collision-free identity alongside the display name through the async context and use that as the isolation key? A duplicate-title E2E regression test would cover this case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch! 😀 I agree

@SimenB SimenB left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplicate-title collision @soltonigiri found is a symptom of the shape, not a bug to patch. I'd rather we removed the key than made it collision-free.

jest-circus has AsyncLocalStorage (run.ts:35). If the store held the per-test state object instead of a name string, expect would just read it:

const getState = () => getCurrentTestState?.() ?? getMatchersObject().state;

That drops the isolatedMatcherStates map, the keying question, the eviction question, and the Proxy in one go. Two things that matter here:

  • run.ts:146 wraps every test in the ALS, not just concurrent ones, so today's Proxy is on the assertion path for all tests. expect/src/index.ts:303 spreads getState() on every expect() call, which runs the ownKeys + getOwnPropertyDescriptor traps per key. I measured ~+4.4µs per assertion; expect(1).toBe(1) is ~11µs to begin with, so that's roughly +40% across the board.
  • Nothing ever deletes from the map, and resetAssertionsLocalState doesn't clear expectedAssertionsNumberError, so every test title that called expect.assertions leaves a retained Error with its stack until the file is done.

Neither survives if the state travels in the ALS store.

One field can come off the list either way: currentTestName doesn't need isolating, since jest-snapshot/src/index.ts:292 already prefers currentConcurrentTestName?.() over it. Isolating it only means reads from outside the ALS scope now see undefined.

globalState: MatcherState,
isolated: IsolatedMatcherState,
): MatcherState =>
new Proxy(globalState, {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this wraps every single test rather than just concurrent ones. is that needed? seems like a bit of overreach that will impact waaaaay more tests than the ones that needs this change


const isolated = createIsolatedMatcherState();
const view = createIsolatedStateView(store.state, isolated);
store.isolatedMatcherStates.set(testName, {isolated, view});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this grows infinitely - maybe clear it somehow after tests are done running?

@SimenB

SimenB commented Aug 18, 2026

Copy link
Copy Markdown
Member

Oh, and also please sign the CLA 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

require-changelog If a PR does requires a changelog entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: expect.assertions fails when using test.concurrent

3 participants