1 # Script to parse many JUnit XML result files and send a report to the buildkite
2 # agent as an annotation.
4 # To run the unittests:
5 # python3 -m unittest discover -p generate_test_report.py
11 from io
import StringIO
12 from junitparser
import JUnitXml
, Failure
13 from textwrap
import dedent
16 def junit_from_xml(xml
):
17 return JUnitXml
.fromfile(StringIO(xml
))
20 class TestReports(unittest
.TestCase
):
21 def test_title_only(self
):
22 self
.assertEqual(_generate_report("Foo", []), ("", "success"))
24 def test_no_tests_in_testsuite(self
):
32 <?xml version="1.0" encoding="UTF-8"?>
33 <testsuites time="0.00">
34 <testsuite name="Empty" tests="0" failures="0" skipped="0" time="0.00">
44 def test_no_failures(self
):
52 <?xml version="1.0" encoding="UTF-8"?>
53 <testsuites time="0.00">
54 <testsuite name="Passed" tests="1" failures="0" skipped="0" time="0.00">
55 <testcase classname="Bar/test_1" name="test_1" time="0.00"/>
73 def test_report_single_file_single_testsuite(self
):
81 <?xml version="1.0" encoding="UTF-8"?>
82 <testsuites time="8.89">
83 <testsuite name="Bar" tests="4" failures="2" skipped="1" time="410.63">
84 <testcase classname="Bar/test_1" name="test_1" time="0.02"/>
85 <testcase classname="Bar/test_2" name="test_2" time="0.02">
86 <skipped message="Reason"/>
88 <testcase classname="Bar/test_3" name="test_3" time="0.02">
89 <failure><![CDATA[Output goes here]]></failure>
91 <testcase classname="Bar/test_4" name="test_4" time="0.02">
92 <failure><![CDATA[Other output goes here]]></failure>
110 (click to see output)
114 <summary>Bar/test_3/test_3</summary>
121 <summary>Bar/test_4/test_4</summary>
124 Other output goes here
132 MULTI_SUITE_OUTPUT
= (
142 (click to see output)
146 <summary>ABC/test_2/test_2</summary>
149 ABC/test_2 output goes here
155 <summary>DEF/test_2/test_2</summary>
158 DEF/test_2 output goes here
165 def test_report_single_file_multiple_testsuites(self
):
173 <?xml version="1.0" encoding="UTF-8"?>
174 <testsuites time="8.89">
175 <testsuite name="ABC" tests="2" failures="1" skipped="0" time="410.63">
176 <testcase classname="ABC/test_1" name="test_1" time="0.02"/>
177 <testcase classname="ABC/test_2" name="test_2" time="0.02">
178 <failure><![CDATA[ABC/test_2 output goes here]]></failure>
181 <testsuite name="DEF" tests="2" failures="1" skipped="1" time="410.63">
182 <testcase classname="DEF/test_1" name="test_1" time="0.02">
183 <skipped message="reason"/>
185 <testcase classname="DEF/test_2" name="test_2" time="0.02">
186 <failure><![CDATA[DEF/test_2 output goes here]]></failure>
194 self
.MULTI_SUITE_OUTPUT
,
197 def test_report_multiple_files_multiple_testsuites(self
):
205 <?xml version="1.0" encoding="UTF-8"?>
206 <testsuites time="8.89">
207 <testsuite name="ABC" tests="2" failures="1" skipped="0" time="410.63">
208 <testcase classname="ABC/test_1" name="test_1" time="0.02"/>
209 <testcase classname="ABC/test_2" name="test_2" time="0.02">
210 <failure><![CDATA[ABC/test_2 output goes here]]></failure>
219 <?xml version="1.0" encoding="UTF-8"?>
220 <testsuites time="8.89">
221 <testsuite name="DEF" tests="2" failures="1" skipped="1" time="410.63">
222 <testcase classname="DEF/test_1" name="test_1" time="0.02">
223 <skipped message="reason"/>
225 <testcase classname="DEF/test_2" name="test_2" time="0.02">
226 <failure><![CDATA[DEF/test_2 output goes here]]></failure>
234 self
.MULTI_SUITE_OUTPUT
,
237 def test_report_dont_list_failures(self
):
245 <?xml version="1.0" encoding="UTF-8"?>
246 <testsuites time="0.02">
247 <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02">
248 <testcase classname="Bar/test_1" name="test_1" time="0.02">
249 <failure><![CDATA[Output goes here]]></failure>
265 Failed tests and their output was too large to report. Download the build's log file to see the details."""
271 def test_report_dont_list_failures_link_to_log(self
):
279 <?xml version="1.0" encoding="UTF-8"?>
280 <testsuites time="0.02">
281 <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02">
282 <testcase classname="Bar/test_1" name="test_1" time="0.02">
283 <failure><![CDATA[Output goes here]]></failure>
292 "BUILDKITE_ORGANIZATION_SLUG": "organization_slug",
293 "BUILDKITE_PIPELINE_SLUG": "pipeline_slug",
294 "BUILDKITE_BUILD_NUMBER": "build_number",
295 "BUILDKITE_JOB_ID": "job_id",
305 Failed tests and their output was too large to report. [Download](https://buildkite.com/organizations/organization_slug/pipelines/pipeline_slug/builds/build_number/jobs/job_id/download.txt) the build's log file to see the details."""
311 def test_report_size_limit(self
):
319 <?xml version="1.0" encoding="UTF-8"?>
320 <testsuites time="0.02">
321 <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02">
322 <testcase classname="Bar/test_1" name="test_1" time="0.02">
323 <failure><![CDATA[Some long output goes here...]]></failure>
339 Failed tests and their output was too large to report. Download the build's log file to see the details."""
346 # Set size_limit to limit the byte size of the report. The default is 1MB as this
347 # is the most that can be put into an annotation. If the generated report exceeds
348 # this limit and failures are listed, it will be generated again without failures
349 # listed. This minimal report will always fit into an annotation.
350 # If include failures is False, total number of test will be reported but their names
351 # and output will not be.
352 def _generate_report(
355 size_limit
=1024 * 1024,
359 if not junit_objects
:
360 return ("", "success")
367 for results
in junit_objects
:
368 for testsuite
in results
:
369 tests_run
+= testsuite
.tests
370 tests_skipped
+= testsuite
.skipped
371 tests_failed
+= testsuite
.failures
373 for test
in testsuite
:
377 and isinstance(test
.result
[0], Failure
)
379 if failures
.get(testsuite
.name
) is None:
380 failures
[testsuite
.name
] = []
381 failures
[testsuite
.name
].append(
382 (test
.classname
+ "/" + test
.name
, test
.result
[0].text
)
388 style
= "error" if tests_failed
else "success"
389 report
= [f
"# {title}", ""]
391 tests_passed
= tests_run
- tests_skipped
- tests_failed
393 def plural(num_tests
):
394 return "test" if num_tests
== 1 else "tests"
397 report
.append(f
"* {tests_passed} {plural(tests_passed)} passed")
399 report
.append(f
"* {tests_skipped} {plural(tests_skipped)} skipped")
401 report
.append(f
"* {tests_failed} {plural(tests_failed)} failed")
403 if not list_failures
:
404 if buildkite_info
is not None:
406 "https://buildkite.com/organizations/{BUILDKITE_ORGANIZATION_SLUG}/"
407 "pipelines/{BUILDKITE_PIPELINE_SLUG}/builds/{BUILDKITE_BUILD_NUMBER}/"
408 "jobs/{BUILDKITE_JOB_ID}/download.txt".format(**buildkite_info
)
410 download_text
= f
"[Download]({log_url})"
412 download_text
= "Download"
417 "Failed tests and their output was too large to report. "
418 f
"{download_text} the build's log file to see the details.",
422 report
.extend(["", "## Failed Tests", "(click to see output)"])
424 for testsuite_name
, failures
in failures
.items():
425 report
.extend(["", f
"### {testsuite_name}"])
426 for name
, output
in failures
:
430 f
"<summary>{name}</summary>",
439 report
= "\n".join(report
)
440 if len(report
.encode("utf-8")) > size_limit
:
441 return _generate_report(
446 buildkite_info
=buildkite_info
,
452 def generate_report(title
, junit_files
, buildkite_info
):
453 return _generate_report(
455 [JUnitXml
.fromfile(p
) for p
in junit_files
],
456 buildkite_info
=buildkite_info
,
460 if __name__
== "__main__":
461 parser
= argparse
.ArgumentParser()
463 "title", help="Title of the test report, without Markdown formatting."
465 parser
.add_argument("context", help="Annotation context to write to.")
466 parser
.add_argument("junit_files", help="Paths to JUnit report files.", nargs
="*")
467 args
= parser
.parse_args()
469 # All of these are required to build a link to download the log file.
471 "BUILDKITE_ORGANIZATION_SLUG",
472 "BUILDKITE_PIPELINE_SLUG",
473 "BUILDKITE_BUILD_NUMBER",
476 buildkite_info
= {k
: v
for k
, v
in os
.environ
.items() if k
in env_var_names
}
477 if len(buildkite_info
) != len(env_var_names
):
478 buildkite_info
= None
480 report
, style
= generate_report(args
.title
, args
.junit_files
, buildkite_info
)
483 p
= subprocess
.Popen(
492 stdin
=subprocess
.PIPE
,
493 stderr
=subprocess
.PIPE
,
494 universal_newlines
=True,
497 # The report can be larger than the buffer for command arguments so we send
498 # it over stdin instead.
499 _
, err
= p
.communicate(input=report
)
501 raise RuntimeError(f
"Failed to send report to buildkite-agent:\n{err}")