This is an automated email from the ASF dual-hosted git repository.
chia7712 pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/trunk by this push:
new 3efa785a651 MINOR: Handle test re-runs in junit.py (#17034)
3efa785a651 is described below
commit 3efa785a651495579ab0feb4cf6647b912da247b
Author: David Arthur <[email protected]>
AuthorDate: Sat Aug 31 11:34:29 2024 -0400
MINOR: Handle test re-runs in junit.py (#17034)
Reviewers: Chia-Ping Tsai <[email protected]>
---
.github/scripts/junit.py | 143 ++++++++++++++++++++++++++++++++++++-----------
1 file changed, 109 insertions(+), 34 deletions(-)
diff --git a/.github/scripts/junit.py b/.github/scripts/junit.py
index 920d3a3b203..30d1296ba54 100644
--- a/.github/scripts/junit.py
+++ b/.github/scripts/junit.py
@@ -13,8 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import argparse
import dataclasses
-import datetime
from functools import partial
from glob import glob
import logging
@@ -31,6 +31,11 @@ handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)
logger.addHandler(handler)
+PASSED = "PASSED ✅"
+FAILED = "FAILED ❌"
+FLAKY = "FLAKY ⚠️ "
+SKIPPED = "SKIPPED 🙈"
+
def get_env(key: str) -> str:
value = os.getenv(key)
@@ -47,6 +52,9 @@ class TestCase:
failure_class: Optional[str]
failure_stack_trace: Optional[str]
+ def key(self) -> Tuple[str, str]:
+ return self.class_name, self.test_name
+
@dataclasses.dataclass
class TestSuite:
@@ -57,20 +65,17 @@ class TestSuite:
failures: int
errors: int
time: float
- test_failures: List[TestCase]
+ failed_tests: List[TestCase]
skipped_tests: List[TestCase]
-
- def errors_and_failures() -> int:
- return self.errors + self.failures
+ passed_tests: List[TestCase]
def parse_report(workspace_path, report_path, fp) -> Iterable[TestSuite]:
- stack = []
- cur_suite = None
- partial_test_failure = None
+ cur_suite: Optional[TestSuite] = None
+ partial_test_case = None
+ test_case_failed = False
for (event, elem) in xml.etree.ElementTree.iterparse(fp, events=["start",
"end"]):
if event == "start":
- stack.append(elem)
if elem.tag == "testsuite":
name = elem.get("name")
tests = int(elem.get("tests", 0))
@@ -78,30 +83,34 @@ def parse_report(workspace_path, report_path, fp) ->
Iterable[TestSuite]:
failures = int(elem.get("failures", 0))
errors = int(elem.get("errors", 0))
suite_time = float(elem.get("time", 0.0))
- cur_suite = TestSuite(name, report_path, tests, skipped,
failures, errors, suite_time, [], [])
+ cur_suite = TestSuite(name, report_path, tests, skipped,
failures, errors, suite_time, [], [], [])
elif elem.tag == "testcase":
test_name = elem.get("name")
class_name = elem.get("classname")
test_time = float(elem.get("time", 0.0))
partial_test_case = partial(TestCase, test_name, class_name,
test_time)
+ test_case_failed = False
elif elem.tag == "failure":
failure_message = elem.get("message")
failure_class = elem.get("type")
failure_stack_trace = elem.text
failure = partial_test_case(failure_message, failure_class,
failure_stack_trace)
- cur_suite.test_failures.append(failure)
- #print(f"{cur_suite}#{cur_test} {elem.attrib}: {elem.text}")
+ cur_suite.failed_tests.append(failure)
+ test_case_failed = True
elif elem.tag == "skipped":
skipped = partial_test_case(None, None, None)
cur_suite.skipped_tests.append(skipped)
else:
pass
elif event == "end":
- stack.pop()
- if elem.tag == "testsuite":
+ if elem.tag == "testcase":
+ if not test_case_failed:
+ passed = partial_test_case(None, None, None)
+ cur_suite.passed_tests.append(passed)
+ partial_test_case = None
+ elif elem.tag == "testsuite":
yield cur_suite
cur_suite = None
- partial_test_failure = None
else:
logger.error(f"Unhandled xml event {event}: {elem}")
@@ -122,55 +131,121 @@ if __name__ == "__main__":
"""
Parse JUnit XML reports and generate GitHub job summary in Markdown format.
+ A Markdown summary of the test results is written to stdout. This should
be redirected to $GITHUB_STEP_SUMMARY
+ within the action. Additional debug logs are written to stderr.
+
Exits with status code 0 if no tests failed, 1 otherwise.
"""
+ parser = argparse.ArgumentParser(description="Parse JUnit XML results.")
+ parser.add_argument("--path",
+ required=False,
+ default="**/test-results/**/*.xml",
+ help="Path to XML files. Glob patterns are supported.")
+
if not os.getenv("GITHUB_WORKSPACE"):
print("This script is intended to by run by GitHub Actions.")
exit(1)
- reports = glob(pathname="**/test-results/**/*.xml", recursive=True)
+ args = parser.parse_args()
+
+ reports = glob(pathname=args.path, recursive=True)
logger.debug(f"Found {len(reports)} JUnit results")
workspace_path = get_env("GITHUB_WORKSPACE") # e.g.,
/home/runner/work/apache/kafka
total_file_count = 0
- total_tests = 0
- total_skipped = 0
- total_failures = 0
- total_errors = 0
- total_time = 0
- table = []
+ total_run = 0 # All test runs according to <testsuite tests="N"/>
+ total_skipped = 0 # All skipped tests according to <testsuite
skipped="N"/>
+ total_errors = 0 # All test errors according to <testsuite errors="N"/>
+ total_time = 0 # All test time according to <testsuite time="N"/>
+ total_failures = 0 # All unique test names that only failed. Re-run tests
not counted
+ total_flaky = 0 # All unique test names that failed and succeeded
+ total_success = 0 # All unique test names that only succeeded. Re-runs
not counted
+ total_tests = 0 # All unique test names that were run. Re-runs not
counted
+
+ failed_table = []
+ flaky_table = []
+ skipped_table = []
+
for report in reports:
with open(report, "r") as fp:
logger.debug(f"Parsing {report}")
for suite in parse_report(workspace_path, report, fp):
- total_tests += suite.tests
total_skipped += suite.skipped
- total_failures += suite.failures
total_errors += suite.errors
total_time += suite.time
- for test_failure in suite.test_failures:
+ total_run += suite.tests
+
+ # Due to how the Develocity Test Retry plugin interacts with
our generated ClusterTests, we can see
+ # tests pass and then fail in the same run. Because of this,
we need to capture all passed and all
+ # failed for each suite. Then we can find flakes by taking the
intersection of those two.
+ all_suite_passed = {test.key() for test in suite.passed_tests}
+ all_suite_failed = {test.key(): test for test in
suite.failed_tests}
+ flaky = all_suite_passed & all_suite_failed.keys()
+ all_tests = all_suite_passed | all_suite_failed.keys()
+ total_tests += len(all_tests)
+ total_flaky += len(flaky)
+ total_failures += len(all_suite_failed) - len(flaky)
+ total_success += len(all_suite_passed) - len(flaky)
+
+ # Display failures first. Iterate across the unique failed
tests to avoid duplicates in table.
+ for test_failure in all_suite_failed.values():
+ if test_failure.key() in flaky:
+ continue
logger.debug(f"Found test failure: {test_failure}")
simple_class_name = test_failure.class_name.split(".")[-1]
- table.append(("❌", simple_class_name,
test_failure.test_name, test_failure.failure_message,
f"{test_failure.time:0.2f}s"))
+ failed_table.append((simple_class_name,
test_failure.test_name, test_failure.failure_message,
f"{test_failure.time:0.2f}s"))
+ for test_failure in all_suite_failed.values():
+ if test_failure.key() not in flaky:
+ continue
+ logger.debug(f"Found flaky test: {test_failure}")
+ simple_class_name = test_failure.class_name.split(".")[-1]
+ flaky_table.append((simple_class_name,
test_failure.test_name, test_failure.failure_message,
f"{test_failure.time:0.2f}s"))
for skipped_test in suite.skipped_tests:
simple_class_name = skipped_test.class_name.split(".")[-1]
logger.debug(f"Found skipped test: {skipped_test}")
- table.append(("⚠️", simple_class_name,
skipped_test.test_name, "Skipped", ""))
+ skipped_table.append((simple_class_name,
skipped_test.test_name))
duration = pretty_time_duration(total_time)
+ logger.info(f"Finished processing {len(reports)} reports")
# Print summary
report_url = get_env("REPORT_URL")
report_md = f"Download [HTML report]({report_url})."
- summary = f"{total_tests} tests run in {duration}, {total_failures} failed
❌, {total_skipped} skipped ⚠️, {total_errors} errors."
- logger.debug(summary)
- print(f"{summary} {report_md}")
- if len(table) > 0:
- print(f"| | Module | Test | Message | Time |")
- print(f"| - | ------ | ---- | ------- | ---- |")
- for row in table:
+ summary = (f"{total_run} tests cases run in {duration}. "
+ f"{total_success} {PASSED}, {total_failures} {FAILED}, "
+ f"{total_flaky} {FLAKY}, {total_skipped} {SKIPPED}, and
{total_errors} errors.")
+ print("## Test Summary\n")
+ print(f"{summary} {report_md}\n")
+ if len(failed_table) > 0:
+ logger.info(f"Found {len(failed_table)} test failures:")
+ print("### Failed Tests\n")
+ print(f"| Module | Test | Message | Time |")
+ print(f"| ------ | ---- | ------- | ---- |")
+ for row in failed_table:
+ logger.info(f"{FAILED} {row[0]} > {row[1]}")
row_joined = " | ".join(row)
print(f"| {row_joined} |")
+ print("\n")
+ if len(flaky_table) > 0:
+ logger.info(f"Found {len(flaky_table)} flaky test failures:")
+ print("### Flaky Tests\n")
+ print(f"| Module | Test | Message | Time |")
+ print(f"| ------ | ---- | ------- | ---- |")
+ for row in flaky_table:
+ logger.info(f"{FLAKY} {row[0]} > {row[1]}")
+ row_joined = " | ".join(row)
+ print(f"| {row_joined} |")
+ print("\n")
+ if len(skipped_table) > 0:
+ print("<details>")
+ print(f"<summary>{len(skipped_table)} Skipped Tests</summary>\n")
+ print(f"| Module | Test |")
+ print(f"| ------ | ---- |")
+ for row in skipped_table:
+ row_joined = " | ".join(row)
+ print(f"| {row_joined} |")
+ print("\n</details>")
+ logger.debug(summary)
if total_failures > 0:
logger.debug(f"Failing this step due to {total_failures} test
failures")
exit(1)