Skip to content

Commit 2c57dcc

Browse files
[test optimization] Fix mocks on jest tests when retried with ATF, EFD or impacted tests (#7352)
1 parent 424bd1b commit 2c57dcc

File tree

5 files changed

+322
-1
lines changed

5 files changed

+322
-1
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use strict'
2+
3+
const mockFn = jest.fn()
4+
5+
describe('early flake detection tests with mock', () => {
6+
it('resets mock state between retries', () => {
7+
// eslint-disable-next-line no-console
8+
console.log('I am running EFD with mock')
9+
10+
// Call the mock function once
11+
mockFn()
12+
13+
// This assertion should pass on every retry because mock state should be reset
14+
// If mock state is NOT reset, this will fail on the 2nd retry with "Expected: 1, Received: 2"
15+
expect(mockFn).toHaveBeenCalledTimes(1)
16+
})
17+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use strict'
2+
3+
const mockFn = jest.fn()
4+
5+
describe('impacted tests with mock', () => {
6+
it('resets mock state between retries', () => {
7+
// eslint-disable-next-line no-console
8+
console.log('I am running impacted test with mock')
9+
10+
// Call the mock function once
11+
mockFn()
12+
13+
// This assertion should pass on every retry because mock state should be reset
14+
// If mock state is NOT reset, this will fail on the 2nd retry with "Expected: 1, Received: 2"
15+
expect(mockFn).toHaveBeenCalledTimes(1)
16+
})
17+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use strict'
2+
3+
const mockFn = jest.fn()
4+
5+
describe('attempt to fix tests with mock', () => {
6+
it('resets mock state between retries', () => {
7+
// eslint-disable-next-line no-console
8+
console.log('I am running attempt to fix with mock')
9+
10+
// Call the mock function once
11+
mockFn()
12+
13+
// This assertion should pass on every retry because mock state should be reset
14+
// If mock state is NOT reset, this will fail on the 2nd retry with "Expected: 1, Received: 2"
15+
expect(mockFn).toHaveBeenCalledTimes(1)
16+
})
17+
})

integration-tests/jest/jest.spec.js

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2170,6 +2170,76 @@ describe(`jest@${JEST_VERSION} commonJS`, () => {
21702170
})
21712171
})
21722172

2173+
it('resets mock state between early flake detection retries', async () => {
2174+
receiver.setInfoResponse({ endpoints: ['/evp_proxy/v4'] })
2175+
// Test is considered new (not in known tests)
2176+
receiver.setKnownTests({ jest: {} })
2177+
const NUM_RETRIES_EFD = 3
2178+
receiver.setSettings({
2179+
early_flake_detection: {
2180+
enabled: true,
2181+
slow_test_retries: {
2182+
'5s': NUM_RETRIES_EFD
2183+
},
2184+
faulty_session_threshold: 100
2185+
},
2186+
known_tests_enabled: true
2187+
})
2188+
2189+
let stdout = ''
2190+
const eventsPromise = receiver
2191+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
2192+
const events = payloads.flatMap(({ payload }) => payload.events)
2193+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
2194+
2195+
// Should have 1 original + NUM_RETRIES_EFD retry attempts
2196+
const mockTests = tests.filter(
2197+
test => test.meta[TEST_NAME] === 'early flake detection tests with mock resets mock state between retries'
2198+
)
2199+
assert.strictEqual(mockTests.length, NUM_RETRIES_EFD + 1)
2200+
2201+
// All tests should pass because mock state is reset between retries
2202+
for (const test of mockTests) {
2203+
assert.strictEqual(test.meta[TEST_STATUS], 'pass')
2204+
}
2205+
2206+
// All should be marked as new
2207+
for (const test of mockTests) {
2208+
assert.strictEqual(test.meta[TEST_IS_NEW], 'true')
2209+
}
2210+
})
2211+
2212+
childProcess = exec(
2213+
runTestsCommand,
2214+
{
2215+
cwd,
2216+
env: {
2217+
...getCiVisEvpProxyConfig(receiver.port),
2218+
TESTS_TO_RUN: 'test-early-flake-detection/test-efd-with-mock'
2219+
},
2220+
}
2221+
)
2222+
2223+
childProcess.stdout?.on('data', (chunk) => {
2224+
stdout += chunk.toString()
2225+
})
2226+
2227+
childProcess.stderr?.on('data', (chunk) => {
2228+
stdout += chunk.toString()
2229+
})
2230+
2231+
const [exitCode] = await Promise.all([
2232+
once(childProcess, 'exit'),
2233+
eventsPromise
2234+
])
2235+
2236+
// Verify the test actually ran
2237+
assert.match(stdout, /I am running EFD with mock/)
2238+
2239+
// All retries should pass, so exit code should be 0
2240+
assert.strictEqual(exitCode[0], 0)
2241+
})
2242+
21732243
it('handles parameterized tests as a single unit', (done) => {
21742244
receiver.setInfoResponse({ endpoints: ['/evp_proxy/v4'] })
21752245
// Tests from ci-visibility/test-early-flake-detection/test-parameterized.js will be considered new
@@ -4208,6 +4278,79 @@ describe(`jest@${JEST_VERSION} commonJS`, () => {
42084278
])
42094279
})
42104280

4281+
it('resets mock state between attempt to fix retries', async () => {
4282+
const NUM_RETRIES = 3
4283+
receiver.setSettings({ test_management: { enabled: true, attempt_to_fix_retries: NUM_RETRIES } })
4284+
4285+
receiver.setTestManagementTests({
4286+
jest: {
4287+
suites: {
4288+
'ci-visibility/test-management/test-attempt-to-fix-with-mock.js': {
4289+
tests: {
4290+
'attempt to fix tests with mock resets mock state between retries': {
4291+
properties: {
4292+
attempt_to_fix: true
4293+
}
4294+
}
4295+
}
4296+
}
4297+
}
4298+
}
4299+
})
4300+
4301+
let stdout = ''
4302+
const eventsPromise = receiver
4303+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
4304+
const events = payloads.flatMap(({ payload }) => payload.events)
4305+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
4306+
4307+
// Should have 1 original + NUM_RETRIES retry attempts
4308+
const mockTests = tests.filter(
4309+
test => test.meta[TEST_NAME] === 'attempt to fix tests with mock resets mock state between retries'
4310+
)
4311+
assert.strictEqual(mockTests.length, NUM_RETRIES + 1)
4312+
4313+
// All tests should pass because mock state is reset between retries
4314+
for (const test of mockTests) {
4315+
assert.strictEqual(test.meta[TEST_STATUS], 'pass')
4316+
}
4317+
4318+
// Last attempt should be marked as attempt_to_fix_passed
4319+
const lastTest = mockTests[mockTests.length - 1]
4320+
assert.strictEqual(lastTest.meta[TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED], 'true')
4321+
})
4322+
4323+
childProcess = exec(
4324+
runTestsCommand,
4325+
{
4326+
cwd,
4327+
env: {
4328+
...getCiVisAgentlessConfig(receiver.port),
4329+
TESTS_TO_RUN: 'test-management/test-attempt-to-fix-with-mock'
4330+
},
4331+
}
4332+
)
4333+
4334+
childProcess.stdout?.on('data', (chunk) => {
4335+
stdout += chunk.toString()
4336+
})
4337+
4338+
childProcess.stderr?.on('data', (chunk) => {
4339+
stdout += chunk.toString()
4340+
})
4341+
4342+
const [exitCode] = await Promise.all([
4343+
once(childProcess, 'exit'),
4344+
eventsPromise
4345+
])
4346+
4347+
// Verify the test actually ran
4348+
assert.match(stdout, /I am running attempt to fix with mock/)
4349+
4350+
// All retries should pass, so exit code should be 0
4351+
assert.strictEqual(exitCode[0], 0)
4352+
})
4353+
42114354
it('does not fail retry if a test is quarantined', (done) => {
42124355
receiver.setSettings({ test_management: { enabled: true, attempt_to_fix_retries: 3 } })
42134356
receiver.setTestManagementTests({
@@ -4936,6 +5079,24 @@ describe(`jest@${JEST_VERSION} commonJS`, () => {
49365079
})`
49375080
)
49385081
execSync('git add ci-visibility/test-impacted-test/test-impacted-1.js', { cwd, stdio: 'ignore' })
5082+
5083+
// Also modify test file with mock for mock state reset test
5084+
fs.writeFileSync(
5085+
path.join(cwd, 'ci-visibility/test-impacted-test/test-impacted-with-mock.js'),
5086+
`'use strict'
5087+
5088+
const mockFn = jest.fn()
5089+
5090+
describe('impacted tests with mock', () => {
5091+
it('resets mock state between retries', () => {
5092+
console.log('I am running impacted test with mock')
5093+
mockFn()
5094+
expect(mockFn).toHaveBeenCalledTimes(1)
5095+
})
5096+
})`
5097+
)
5098+
execSync('git add ci-visibility/test-impacted-test/test-impacted-with-mock.js', { cwd, stdio: 'ignore' })
5099+
49395100
execSync('git commit -m "modify test-impacted-1.js"', { cwd, stdio: 'ignore' })
49405101
})
49415102

@@ -5161,6 +5322,76 @@ describe(`jest@${JEST_VERSION} commonJS`, () => {
51615322
})
51625323
runImpactedTest(done, { isModified: true, isEfd: true, isNew: true })
51635324
})
5325+
5326+
it('resets mock state between impacted test retries', async () => {
5327+
// Test is considered new (not in known tests)
5328+
receiver.setKnownTests({ jest: {} })
5329+
receiver.setSettings({
5330+
impacted_tests_enabled: true,
5331+
early_flake_detection: {
5332+
enabled: true,
5333+
slow_test_retries: {
5334+
'5s': NUM_RETRIES
5335+
},
5336+
faulty_session_threshold: 100
5337+
},
5338+
known_tests_enabled: true
5339+
})
5340+
5341+
let stdout = ''
5342+
const eventsPromise = receiver
5343+
.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => {
5344+
const events = payloads.flatMap(({ payload }) => payload.events)
5345+
const tests = events.filter(event => event.type === 'test').map(event => event.content)
5346+
5347+
// Should have 1 original + NUM_RETRIES retry attempts
5348+
const mockTests = tests.filter(
5349+
test => test.meta[TEST_NAME] === 'impacted tests with mock resets mock state between retries'
5350+
)
5351+
assert.strictEqual(mockTests.length, NUM_RETRIES + 1)
5352+
5353+
// All tests should pass because mock state is reset between retries
5354+
for (const test of mockTests) {
5355+
assert.strictEqual(test.meta[TEST_STATUS], 'pass')
5356+
}
5357+
5358+
// All should be marked as modified (impacted)
5359+
for (const test of mockTests) {
5360+
assert.strictEqual(test.meta[TEST_IS_MODIFIED], 'true')
5361+
}
5362+
})
5363+
5364+
childProcess = exec(
5365+
runTestsCommand,
5366+
{
5367+
cwd,
5368+
env: {
5369+
...getCiVisAgentlessConfig(receiver.port),
5370+
TESTS_TO_RUN: 'test-impacted-test/test-impacted-with-mock',
5371+
GITHUB_BASE_REF: ''
5372+
},
5373+
}
5374+
)
5375+
5376+
childProcess.stdout?.on('data', (chunk) => {
5377+
stdout += chunk.toString()
5378+
})
5379+
5380+
childProcess.stderr?.on('data', (chunk) => {
5381+
stdout += chunk.toString()
5382+
})
5383+
5384+
const [exitCode] = await Promise.all([
5385+
once(childProcess, 'exit'),
5386+
eventsPromise
5387+
])
5388+
5389+
// Verify the test actually ran
5390+
assert.match(stdout, /I am running impacted test with mock/)
5391+
5392+
// All retries should pass, so exit code should be 0
5393+
assert.strictEqual(exitCode[0], 0)
5394+
})
51645395
})
51655396
})
51665397

packages/datadog-instrumentations/src/jest.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ const wrappedWorkers = new WeakSet()
9191
const testSuiteMockedFiles = new Map()
9292
const testsToBeRetried = new Set()
9393
const testSuiteAbsolutePathsWithFastCheck = new Set()
94+
const testSuiteJestObjects = new Map()
9495

9596
const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200
9697
const ATR_RETRY_SUPPRESSION_FLAG = '_ddDisableAtrRetry'
@@ -237,6 +238,28 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
237238
}
238239
}
239240

241+
/**
242+
* Jest mock state issue during test retries
243+
*
244+
* Problem:
245+
* - Jest tracks mock function calls using internal state (call count, call arguments, etc.)
246+
* - When a test is retried, the mock state is not automatically reset
247+
* - This causes assertions like `toHaveBeenCalledTimes(1)` to fail because the call count
248+
* accumulates across retries
249+
*
250+
* The solution is to clear all mocks before each retry attempt.
251+
*/
252+
resetMockState () {
253+
try {
254+
const jestObject = testSuiteJestObjects.get(this.testSuiteAbsolutePath)
255+
if (jestObject?.clearAllMocks) {
256+
jestObject.clearAllMocks()
257+
}
258+
} catch (e) {
259+
log.warn('Error resetting mock state', e)
260+
}
261+
}
262+
240263
// This function returns an array if the known tests are valid and null otherwise.
241264
getKnownTestsForSuite (suiteKnownTests) {
242265
// `suiteKnownTests` is `this.testEnvironmentOptions._ddKnownTests`,
@@ -339,8 +362,9 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) {
339362
if (event.name === 'test_start') {
340363
const testName = getJestTestName(event.test, this.getShouldStripSeedFromTestName())
341364
if (testsToBeRetried.has(testName)) {
342-
// This is needed because we're trying tests with the same name
365+
// This is needed because we're retrying tests with the same name
343366
this.resetSnapshotState()
367+
this.resetMockState()
344368
}
345369

346370
let isNewTest = false
@@ -1148,9 +1172,19 @@ function jestAdapterWrapper (jestAdapter, jestVersion) {
11481172
})
11491173
}
11501174
testSuiteFinishCh.publish({ status, errorMessage, testSuiteAbsolutePath: environment.testSuiteAbsolutePath })
1175+
1176+
// Cleanup per-suite state to avoid memory leaks
1177+
testSuiteMockedFiles.delete(environment.testSuiteAbsolutePath)
1178+
testSuiteJestObjects.delete(environment.testSuiteAbsolutePath)
1179+
11511180
return suiteResults
11521181
}).catch(error => {
11531182
testSuiteFinishCh.publish({ status: 'fail', error, testSuiteAbsolutePath: environment.testSuiteAbsolutePath })
1183+
1184+
// Cleanup per-suite state to avoid memory leaks
1185+
testSuiteMockedFiles.delete(environment.testSuiteAbsolutePath)
1186+
testSuiteJestObjects.delete(environment.testSuiteAbsolutePath)
1187+
11541188
throw error
11551189
})
11561190
})
@@ -1312,6 +1346,11 @@ addHook({
13121346
const result = _createJestObjectFor.apply(this, arguments)
13131347
const suiteFilePath = this._testPath || from
13141348

1349+
// Store the jest object so we can access it later for resetting mock state
1350+
if (suiteFilePath) {
1351+
testSuiteJestObjects.set(suiteFilePath, result)
1352+
}
1353+
13151354
shimmer.wrap(result, 'mock', mock => function (moduleName) {
13161355
// If the library is mocked with `jest.mock`, we don't want to bypass jest's own require engine
13171356
if (LIBRARIES_BYPASSING_JEST_REQUIRE_ENGINE.has(moduleName)) {

0 commit comments

Comments
 (0)