Isolate expect.assertions state per test.concurrent - #16340
Conversation
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
✅ Deploy Preview for jestjs ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
CI Static Checks failed: assigning through IsolatedStateKey was typed as never.
babel-jest
babel-plugin-jest-hoist
babel-preset-jest
create-jest
@jest/diff-sequences
expect
@jest/expect-utils
jest
jest-changed-files
jest-circus
jest-cli
jest-config
@jest/console
@jest/core
@jest/create-cache-key-function
jest-diff
jest-docblock
jest-each
@jest/environment
jest-environment-jsdom
@jest/environment-jsdom-abstract
jest-environment-node
@jest/expect
@jest/fake-timers
@jest/get-type
@jest/globals
jest-haste-map
jest-jasmine2
jest-leak-detector
jest-matcher-utils
jest-message-util
jest-mock
@jest/pattern
jest-phabricator
jest-regex-util
@jest/reporters
jest-resolve
jest-resolve-dependencies
jest-runner
jest-runtime
@jest/schemas
jest-snapshot
@jest/snapshot-utils
@jest/source-map
@jest/test-result
@jest/test-sequencer
@jest/transform
@jest/types
jest-util
jest-validate
jest-watcher
jest-worker
pretty-format
commit: |
|
recheck |
soltonigiri
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
SimenB
left a comment
There was a problem hiding this comment.
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:146wraps every test in the ALS, not just concurrent ones, so today'sProxyis on the assertion path for all tests.expect/src/index.ts:303spreadsgetState()on everyexpect()call, which runs theownKeys+getOwnPropertyDescriptortraps 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
resetAssertionsLocalStatedoesn't clearexpectedAssertionsNumberError, so every test title that calledexpect.assertionsleaves a retainedErrorwith 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, { |
There was a problem hiding this comment.
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}); |
There was a problem hiding this comment.
this grows infinitely - maybe clear it somehow after tests are done running?
|
Oh, and also please sign the CLA 🙂 |
Summary
expect.assertions(n)andexpect.hasAssertions()store their counters on a single global matcher state object. When twotest.concurrentcases run at the same time, they increment and check the sameassertionCallsfield, so a pair of tests that each declareexpect.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 byAsyncLocalStorage).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
packages/expectinterleave two synthetic concurrent names and assert that each test only sees its own assertion count /hasAssertionsresult.jest-circusdriveeventHandlertest_donefor two overlapping concurrent names.e2e/circus-concurrentcover passing concurrent + sequentialexpect.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