Skip to content

Commit a67ac83

Browse files
authored
Merge pull request #1482 from mikepenz/fix/1480_1463
Improve handling of retrying tests | Fix wrong merging if test-name is equal
2 parents 6320bc0 + 296f468 commit a67ac83

File tree

7 files changed

+239
-23
lines changed

7 files changed

+239
-23
lines changed

__tests__/testParser.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,129 @@ action.surefire.report.email.InvalidEmailAddressException: Invalid email address
14131413
}
14141414
])
14151415
})
1416+
1417+
it('flaky test with classname and file: multiple failures then success should pass with retries', async () => {
1418+
// Test that flaky tests are correctly identified using classname and file as part of the key
1419+
// The test_foo test appears 3 times: failure, error, then success
1420+
// It should be marked as success with 2 retries
1421+
const testResult = await parseFile(
1422+
'test_results/flaky_retries/flaky_with_classname_file.xml',
1423+
'',
1424+
true, // includePassed
1425+
true, // annotateNotice
1426+
true // checkRetries
1427+
)
1428+
expect(testResult).toBeDefined()
1429+
const {totalCount, skippedCount, failedCount, passedCount, retriedCount, globalAnnotations} = testResult!!
1430+
1431+
// Should have 3 unique tests (test_foo appears once due to deduplication, plus test_bar.test_foo and test_baz)
1432+
expect(totalCount).toBe(3)
1433+
expect(skippedCount).toBe(0)
1434+
expect(failedCount).toBe(0)
1435+
expect(passedCount).toBe(3)
1436+
expect(retriedCount).toBe(2) // 2 retries for the flaky test (3 occurrences - 1)
1437+
1438+
// Find the flaky test annotation
1439+
const flakyTest = globalAnnotations.find(a =>
1440+
a.title.includes('test_foo.TestFoo') || a.path.includes('test_foo.py')
1441+
)
1442+
expect(flakyTest).toBeDefined()
1443+
expect(flakyTest!.status).toBe('success')
1444+
expect(flakyTest!.retries).toBe(2)
1445+
expect(flakyTest!.annotation_level).toBe('notice')
1446+
1447+
// Verify that test_bar.test_foo is NOT merged with test_foo.test_foo (different classname/file)
1448+
const testBarFoo = globalAnnotations.find(a => a.path.includes('test_bar.py'))
1449+
expect(testBarFoo).toBeDefined()
1450+
expect(testBarFoo!.retries).toBe(0) // Not retried, it's a separate test
1451+
})
1452+
1453+
it('flaky test with all failures should still be marked as failure with retries', async () => {
1454+
// Test that when all executions of a flaky test fail, it remains a failure but tracks retries
1455+
const testResult = await parseFile(
1456+
'test_results/flaky_retries/flaky_all_failures.xml',
1457+
'',
1458+
false, // includePassed
1459+
false, // annotateNotice
1460+
true // checkRetries
1461+
)
1462+
expect(testResult).toBeDefined()
1463+
const {totalCount, skippedCount, failedCount, passedCount, retriedCount, globalAnnotations} = testResult!!
1464+
1465+
// Should have 1 unique test after deduplication
1466+
expect(totalCount).toBe(1)
1467+
expect(skippedCount).toBe(0)
1468+
expect(failedCount).toBe(1)
1469+
expect(passedCount).toBe(0)
1470+
expect(retriedCount).toBe(2) // 2 retries (3 occurrences - 1)
1471+
1472+
// Should still have a failure annotation
1473+
expect(globalAnnotations).toHaveLength(1)
1474+
expect(globalAnnotations[0].status).toBe('failure')
1475+
expect(globalAnnotations[0].retries).toBe(2)
1476+
expect(globalAnnotations[0].annotation_level).toBe('failure')
1477+
})
1478+
1479+
it('flaky test with success first should still pass with retries tracked', async () => {
1480+
// Test that even if success comes first and failures come later,
1481+
// the test is still marked as success with proper retry count
1482+
const testResult = await parseFile(
1483+
'test_results/flaky_retries/flaky_success_first.xml',
1484+
'',
1485+
true, // includePassed
1486+
true, // annotateNotice
1487+
true // checkRetries
1488+
)
1489+
expect(testResult).toBeDefined()
1490+
const {totalCount, skippedCount, failedCount, passedCount, retriedCount, globalAnnotations} = testResult!!
1491+
1492+
// Should have 1 unique test after deduplication
1493+
expect(totalCount).toBe(1)
1494+
expect(skippedCount).toBe(0)
1495+
expect(failedCount).toBe(0)
1496+
expect(passedCount).toBe(1)
1497+
expect(retriedCount).toBe(2) // 2 retries (3 occurrences - 1)
1498+
1499+
// Should be marked as success
1500+
expect(globalAnnotations).toHaveLength(1)
1501+
expect(globalAnnotations[0].status).toBe('success')
1502+
expect(globalAnnotations[0].retries).toBe(2)
1503+
expect(globalAnnotations[0].annotation_level).toBe('notice')
1504+
})
1505+
1506+
it('same test name but different classname/file should NOT be merged', async () => {
1507+
// Verify that tests with the same name but different classname or file are treated as separate tests
1508+
const testResult = await parseFile(
1509+
'test_results/flaky_retries/flaky_with_classname_file.xml',
1510+
'',
1511+
true, // includePassed
1512+
true, // annotateNotice
1513+
true // checkRetries
1514+
)
1515+
expect(testResult).toBeDefined()
1516+
const {totalCount, globalAnnotations} = testResult!!
1517+
1518+
// Should have 3 unique tests:
1519+
// 1. test_foo from test_foo.TestFoo (flaky, merged)
1520+
// 2. test_foo from test_bar.TestBar (separate)
1521+
// 3. test_baz from test_baz.TestBaz
1522+
expect(totalCount).toBe(3)
1523+
expect(globalAnnotations).toHaveLength(3)
1524+
1525+
// Verify we have two different test_foo entries (one from each classname)
1526+
const testFooAnnotations = globalAnnotations.filter(a => a.title.includes('test_foo'))
1527+
expect(testFooAnnotations).toHaveLength(2)
1528+
1529+
// The one from TestFoo should have retries, the one from TestBar should not
1530+
const testFooFromTestFoo = testFooAnnotations.find(a => a.path.includes('test_foo.py'))
1531+
const testFooFromTestBar = testFooAnnotations.find(a => a.path.includes('test_bar.py'))
1532+
1533+
expect(testFooFromTestFoo).toBeDefined()
1534+
expect(testFooFromTestFoo!.retries).toBe(2)
1535+
1536+
expect(testFooFromTestBar).toBeDefined()
1537+
expect(testFooFromTestBar!.retries).toBe(0)
1538+
})
14161539
})
14171540

14181541
describe('parseTestReports', () => {

dist/index.js

Lines changed: 25 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/testParser.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -582,25 +582,41 @@ async function parseTestCases(
582582
let time = 0
583583
if (checkRetries) {
584584
// identify duplicates in case of flaky tests, and remove them
585+
// Use a compound key including name, classname (if available), and file (if available)
586+
// to prevent accidental duplicate matches across different test classes/files
585587
const testcaseMap = new Map<string, any>()
586588
for (const testcase of testcases) {
587-
const key = testcase._attributes.name
589+
const name = testcase._attributes.name
590+
const classname = testcase._attributes.classname || ''
591+
const file = testcase._attributes.file || ''
592+
const key = `${name}|${classname}|${file}`
593+
588594
if (testcaseMap.get(key) !== undefined) {
589-
// testcase with matching name exists
595+
// testcase with matching key exists - this is a flaky test
590596
const failed = testcase.failure || testcase.error
591597
const previous = testcaseMap.get(key)
592598
const previousFailed = previous.failure || previous.error
593-
if (failed && !previousFailed) {
594-
// previous is a success, drop failure
595-
previous.retries = (previous.retries || 0) + 1
596-
retriedCount += 1
597-
core.debug(`Drop flaky test failure for (1): ${key}`)
598-
} else if (!failed && previousFailed) {
599-
// previous failed, new one not, replace
600-
testcase.retries = (previous.retries || 0) + 1
599+
600+
// Increment retry count for each additional occurrence
601+
const currentRetries = (previous.retries || 0) + 1
602+
603+
if (!failed) {
604+
// Current execution is successful - use this as the final result
605+
// The test is flaky but ultimately passed
606+
testcase.retries = currentRetries
601607
testcaseMap.set(key, testcase)
602608
retriedCount += 1
603-
core.debug(`Drop flaky test failure for (2): ${JSON.stringify(testcase)}`)
609+
core.debug(`Flaky test succeeded after retry for: ${key}`)
610+
} else if (!previousFailed) {
611+
// Previous was successful, current failed - keep the successful one
612+
previous.retries = currentRetries
613+
retriedCount += 1
614+
core.debug(`Flaky test: keeping success, dropping failure for: ${key}`)
615+
} else {
616+
// Both failed - keep tracking retries but keep the previous
617+
previous.retries = currentRetries
618+
retriedCount += 1
619+
core.debug(`Flaky test: multiple failures for: ${key}`)
604620
}
605621
} else {
606622
testcaseMap.set(key, testcase)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<testsuite errors="1" failures="2" name="FlakyAllFailures" skips="0" time="3.0" tests="3">
3+
<!-- Flaky test: fails multiple times and never passes -->
4+
<testcase classname="test_always_fails.TestAlwaysFails"
5+
file="/home/runner/work/tests/test_always_fails.py"
6+
line="10" name="test_always_fails" time="1.0">
7+
<failure message="first failure">First attempt failed</failure>
8+
</testcase>
9+
<testcase classname="test_always_fails.TestAlwaysFails"
10+
file="/home/runner/work/tests/test_always_fails.py"
11+
line="10" name="test_always_fails" time="1.0">
12+
<failure message="second failure">Second attempt failed</failure>
13+
</testcase>
14+
<testcase classname="test_always_fails.TestAlwaysFails"
15+
file="/home/runner/work/tests/test_always_fails.py"
16+
line="10" name="test_always_fails" time="1.0">
17+
<error message="third error">Third attempt errored</error>
18+
</testcase>
19+
</testsuite>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<testsuite errors="1" failures="1" name="FlakySuccessFirst" skips="0" time="3.0" tests="3">
3+
<!-- Flaky test: passes first, then fails (unusual order but should still work) -->
4+
<testcase classname="test_order.TestOrder"
5+
file="/home/runner/work/tests/test_order.py"
6+
line="15" name="test_success_first" time="1.0" />
7+
<testcase classname="test_order.TestOrder"
8+
file="/home/runner/work/tests/test_order.py"
9+
line="15" name="test_success_first" time="1.0">
10+
<failure message="later failure">This failure came after success</failure>
11+
</testcase>
12+
<testcase classname="test_order.TestOrder"
13+
file="/home/runner/work/tests/test_order.py"
14+
line="15" name="test_success_first" time="1.0">
15+
<error message="later error">This error came after success</error>
16+
</testcase>
17+
</testsuite>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<testsuite errors="2" failures="1" name="FlakyTestSuite" skips="0" time="5.526" tests="5">
3+
<!-- Flaky test: fails with failure first, then error, then passes -->
4+
<testcase classname="test_foo.TestFoo"
5+
file="/home/runner/work/foo/bar/test_foo.py"
6+
line="42" name="test_foo" time="1.842">
7+
<failure message="it failed">'NoneType' object is not iterable</failure>
8+
</testcase>
9+
<testcase classname="test_foo.TestFoo"
10+
file="/home/runner/work/foo/bar/test_foo.py"
11+
line="42" name="test_foo" time="1.842">
12+
<error message="it broke">oh no</error>
13+
</testcase>
14+
<testcase classname="test_foo.TestFoo"
15+
file="/home/runner/work/foo/bar/test_foo.py"
16+
line="42" name="test_foo" time="1.842" />
17+
18+
<!-- Regular passing test with same name but different classname - should NOT be merged -->
19+
<testcase classname="test_bar.TestBar"
20+
file="/home/runner/work/foo/bar/test_bar.py"
21+
line="10" name="test_foo" time="0.5" />
22+
23+
<!-- Another regular test -->
24+
<testcase classname="test_baz.TestBaz"
25+
file="/home/runner/work/foo/bar/test_baz.py"
26+
line="20" name="test_baz" time="0.5" />
27+
</testsuite>

0 commit comments

Comments
 (0)