1# Script to parse many JUnit XML result files and send a report to the buildkite 2# agent as an annotation. 3# 4# To run the unittests: 5# python3 -m unittest discover -p generate_test_report.py 6 7import argparse 8import os 9import subprocess 10import unittest 11from io import StringIO 12from junitparser import JUnitXml, Failure 13from textwrap import dedent 14 15 16def junit_from_xml(xml): 17 return JUnitXml.fromfile(StringIO(xml)) 18 19 20class TestReports(unittest.TestCase): 21 def test_title_only(self): 22 self.assertEqual(_generate_report("Foo", 0, []), ("", "success")) 23 24 def test_no_tests_in_testsuite(self): 25 self.assertEqual( 26 _generate_report( 27 "Foo", 28 1, 29 [ 30 junit_from_xml( 31 dedent( 32 """\ 33 <?xml version="1.0" encoding="UTF-8"?> 34 <testsuites time="0.00"> 35 <testsuite name="Empty" tests="0" failures="0" skipped="0" time="0.00"> 36 </testsuite> 37 </testsuites>""" 38 ) 39 ) 40 ], 41 ), 42 ("", None), 43 ) 44 45 def test_no_failures(self): 46 self.assertEqual( 47 _generate_report( 48 "Foo", 49 0, 50 [ 51 junit_from_xml( 52 dedent( 53 """\ 54 <?xml version="1.0" encoding="UTF-8"?> 55 <testsuites time="0.00"> 56 <testsuite name="Passed" tests="1" failures="0" skipped="0" time="0.00"> 57 <testcase classname="Bar/test_1" name="test_1" time="0.00"/> 58 </testsuite> 59 </testsuites>""" 60 ) 61 ) 62 ], 63 ), 64 ( 65 dedent( 66 """\ 67 # Foo 68 69 * 1 test passed""" 70 ), 71 "success", 72 ), 73 ) 74 75 def test_no_failures_build_failed(self): 76 self.assertEqual( 77 _generate_report( 78 "Foo", 79 1, 80 [ 81 junit_from_xml( 82 dedent( 83 """\ 84 <?xml version="1.0" encoding="UTF-8"?> 85 <testsuites time="0.00"> 86 <testsuite name="Passed" tests="1" failures="0" skipped="0" time="0.00"> 87 <testcase classname="Bar/test_1" name="test_1" time="0.00"/> 88 </testsuite> 89 </testsuites>""" 90 ) 91 ) 92 ], 93 buildkite_info={ 94 "BUILDKITE_ORGANIZATION_SLUG": "organization_slug", 95 "BUILDKITE_PIPELINE_SLUG": "pipeline_slug", 96 "BUILDKITE_BUILD_NUMBER": "build_number", 97 "BUILDKITE_JOB_ID": "job_id", 98 }, 99 ), 100 ( 101 dedent( 102 """\ 103 # Foo 104 105 * 1 test passed 106 107 All tests passed but another part of the build **failed**. 108 109 [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.""" 110 ), 111 "error", 112 ), 113 ) 114 115 def test_report_single_file_single_testsuite(self): 116 self.assertEqual( 117 _generate_report( 118 "Foo", 119 1, 120 [ 121 junit_from_xml( 122 dedent( 123 """\ 124 <?xml version="1.0" encoding="UTF-8"?> 125 <testsuites time="8.89"> 126 <testsuite name="Bar" tests="4" failures="2" skipped="1" time="410.63"> 127 <testcase classname="Bar/test_1" name="test_1" time="0.02"/> 128 <testcase classname="Bar/test_2" name="test_2" time="0.02"> 129 <skipped message="Reason"/> 130 </testcase> 131 <testcase classname="Bar/test_3" name="test_3" time="0.02"> 132 <failure><![CDATA[Output goes here]]></failure> 133 </testcase> 134 <testcase classname="Bar/test_4" name="test_4" time="0.02"> 135 <failure><![CDATA[Other output goes here]]></failure> 136 </testcase> 137 </testsuite> 138 </testsuites>""" 139 ) 140 ) 141 ], 142 ), 143 ( 144 dedent( 145 """\ 146 # Foo 147 148 * 1 test passed 149 * 1 test skipped 150 * 2 tests failed 151 152 ## Failed Tests 153 (click to see output) 154 155 ### Bar 156 <details> 157 <summary>Bar/test_3/test_3</summary> 158 159 ``` 160 Output goes here 161 ``` 162 </details> 163 <details> 164 <summary>Bar/test_4/test_4</summary> 165 166 ``` 167 Other output goes here 168 ``` 169 </details>""" 170 ), 171 "error", 172 ), 173 ) 174 175 MULTI_SUITE_OUTPUT = ( 176 dedent( 177 """\ 178 # ABC and DEF 179 180 * 1 test passed 181 * 1 test skipped 182 * 2 tests failed 183 184 ## Failed Tests 185 (click to see output) 186 187 ### ABC 188 <details> 189 <summary>ABC/test_2/test_2</summary> 190 191 ``` 192 ABC/test_2 output goes here 193 ``` 194 </details> 195 196 ### DEF 197 <details> 198 <summary>DEF/test_2/test_2</summary> 199 200 ``` 201 DEF/test_2 output goes here 202 ``` 203 </details>""" 204 ), 205 "error", 206 ) 207 208 def test_report_single_file_multiple_testsuites(self): 209 self.assertEqual( 210 _generate_report( 211 "ABC and DEF", 212 1, 213 [ 214 junit_from_xml( 215 dedent( 216 """\ 217 <?xml version="1.0" encoding="UTF-8"?> 218 <testsuites time="8.89"> 219 <testsuite name="ABC" tests="2" failures="1" skipped="0" time="410.63"> 220 <testcase classname="ABC/test_1" name="test_1" time="0.02"/> 221 <testcase classname="ABC/test_2" name="test_2" time="0.02"> 222 <failure><![CDATA[ABC/test_2 output goes here]]></failure> 223 </testcase> 224 </testsuite> 225 <testsuite name="DEF" tests="2" failures="1" skipped="1" time="410.63"> 226 <testcase classname="DEF/test_1" name="test_1" time="0.02"> 227 <skipped message="reason"/> 228 </testcase> 229 <testcase classname="DEF/test_2" name="test_2" time="0.02"> 230 <failure><![CDATA[DEF/test_2 output goes here]]></failure> 231 </testcase> 232 </testsuite> 233 </testsuites>""" 234 ) 235 ) 236 ], 237 ), 238 self.MULTI_SUITE_OUTPUT, 239 ) 240 241 def test_report_multiple_files_multiple_testsuites(self): 242 self.assertEqual( 243 _generate_report( 244 "ABC and DEF", 245 1, 246 [ 247 junit_from_xml( 248 dedent( 249 """\ 250 <?xml version="1.0" encoding="UTF-8"?> 251 <testsuites time="8.89"> 252 <testsuite name="ABC" tests="2" failures="1" skipped="0" time="410.63"> 253 <testcase classname="ABC/test_1" name="test_1" time="0.02"/> 254 <testcase classname="ABC/test_2" name="test_2" time="0.02"> 255 <failure><![CDATA[ABC/test_2 output goes here]]></failure> 256 </testcase> 257 </testsuite> 258 </testsuites>""" 259 ) 260 ), 261 junit_from_xml( 262 dedent( 263 """\ 264 <?xml version="1.0" encoding="UTF-8"?> 265 <testsuites time="8.89"> 266 <testsuite name="DEF" tests="2" failures="1" skipped="1" time="410.63"> 267 <testcase classname="DEF/test_1" name="test_1" time="0.02"> 268 <skipped message="reason"/> 269 </testcase> 270 <testcase classname="DEF/test_2" name="test_2" time="0.02"> 271 <failure><![CDATA[DEF/test_2 output goes here]]></failure> 272 </testcase> 273 </testsuite> 274 </testsuites>""" 275 ) 276 ), 277 ], 278 ), 279 self.MULTI_SUITE_OUTPUT, 280 ) 281 282 def test_report_dont_list_failures(self): 283 self.assertEqual( 284 _generate_report( 285 "Foo", 286 1, 287 [ 288 junit_from_xml( 289 dedent( 290 """\ 291 <?xml version="1.0" encoding="UTF-8"?> 292 <testsuites time="0.02"> 293 <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02"> 294 <testcase classname="Bar/test_1" name="test_1" time="0.02"> 295 <failure><![CDATA[Output goes here]]></failure> 296 </testcase> 297 </testsuite> 298 </testsuites>""" 299 ) 300 ) 301 ], 302 list_failures=False, 303 ), 304 ( 305 dedent( 306 """\ 307 # Foo 308 309 * 1 test failed 310 311 Failed tests and their output was too large to report. Download the build's log file to see the details.""" 312 ), 313 "error", 314 ), 315 ) 316 317 def test_report_dont_list_failures_link_to_log(self): 318 self.assertEqual( 319 _generate_report( 320 "Foo", 321 1, 322 [ 323 junit_from_xml( 324 dedent( 325 """\ 326 <?xml version="1.0" encoding="UTF-8"?> 327 <testsuites time="0.02"> 328 <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02"> 329 <testcase classname="Bar/test_1" name="test_1" time="0.02"> 330 <failure><![CDATA[Output goes here]]></failure> 331 </testcase> 332 </testsuite> 333 </testsuites>""" 334 ) 335 ) 336 ], 337 list_failures=False, 338 buildkite_info={ 339 "BUILDKITE_ORGANIZATION_SLUG": "organization_slug", 340 "BUILDKITE_PIPELINE_SLUG": "pipeline_slug", 341 "BUILDKITE_BUILD_NUMBER": "build_number", 342 "BUILDKITE_JOB_ID": "job_id", 343 }, 344 ), 345 ( 346 dedent( 347 """\ 348 # Foo 349 350 * 1 test failed 351 352 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.""" 353 ), 354 "error", 355 ), 356 ) 357 358 def test_report_size_limit(self): 359 self.assertEqual( 360 _generate_report( 361 "Foo", 362 1, 363 [ 364 junit_from_xml( 365 dedent( 366 """\ 367 <?xml version="1.0" encoding="UTF-8"?> 368 <testsuites time="0.02"> 369 <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02"> 370 <testcase classname="Bar/test_1" name="test_1" time="0.02"> 371 <failure><![CDATA[Some long output goes here...]]></failure> 372 </testcase> 373 </testsuite> 374 </testsuites>""" 375 ) 376 ) 377 ], 378 size_limit=128, 379 ), 380 ( 381 dedent( 382 """\ 383 # Foo 384 385 * 1 test failed 386 387 Failed tests and their output was too large to report. Download the build's log file to see the details.""" 388 ), 389 "error", 390 ), 391 ) 392 393 394# Set size_limit to limit the byte size of the report. The default is 1MB as this 395# is the most that can be put into an annotation. If the generated report exceeds 396# this limit and failures are listed, it will be generated again without failures 397# listed. This minimal report will always fit into an annotation. 398# If include failures is False, total number of test will be reported but their names 399# and output will not be. 400def _generate_report( 401 title, 402 return_code, 403 junit_objects, 404 size_limit=1024 * 1024, 405 list_failures=True, 406 buildkite_info=None, 407): 408 if not junit_objects: 409 # Note that we do not post an empty report, therefore we can ignore a 410 # non-zero return code in situations like this. 411 # 412 # If we were going to post a report, then yes, it would be misleading 413 # to say we succeeded when the final return code was non-zero. 414 return ("", "success") 415 416 failures = {} 417 tests_run = 0 418 tests_skipped = 0 419 tests_failed = 0 420 421 for results in junit_objects: 422 for testsuite in results: 423 tests_run += testsuite.tests 424 tests_skipped += testsuite.skipped 425 tests_failed += testsuite.failures 426 427 for test in testsuite: 428 if ( 429 not test.is_passed 430 and test.result 431 and isinstance(test.result[0], Failure) 432 ): 433 if failures.get(testsuite.name) is None: 434 failures[testsuite.name] = [] 435 failures[testsuite.name].append( 436 (test.classname + "/" + test.name, test.result[0].text) 437 ) 438 439 if not tests_run: 440 return ("", None) 441 442 style = "success" 443 # Either tests failed, or all tests passed but something failed to build. 444 if tests_failed or return_code != 0: 445 style = "error" 446 447 report = [f"# {title}", ""] 448 449 tests_passed = tests_run - tests_skipped - tests_failed 450 451 def plural(num_tests): 452 return "test" if num_tests == 1 else "tests" 453 454 if tests_passed: 455 report.append(f"* {tests_passed} {plural(tests_passed)} passed") 456 if tests_skipped: 457 report.append(f"* {tests_skipped} {plural(tests_skipped)} skipped") 458 if tests_failed: 459 report.append(f"* {tests_failed} {plural(tests_failed)} failed") 460 461 if buildkite_info is not None: 462 log_url = ( 463 "https://buildkite.com/organizations/{BUILDKITE_ORGANIZATION_SLUG}/" 464 "pipelines/{BUILDKITE_PIPELINE_SLUG}/builds/{BUILDKITE_BUILD_NUMBER}/" 465 "jobs/{BUILDKITE_JOB_ID}/download.txt".format(**buildkite_info) 466 ) 467 download_text = f"[Download]({log_url})" 468 else: 469 download_text = "Download" 470 471 if not list_failures: 472 report.extend( 473 [ 474 "", 475 "Failed tests and their output was too large to report. " 476 f"{download_text} the build's log file to see the details.", 477 ] 478 ) 479 elif failures: 480 report.extend(["", "## Failed Tests", "(click to see output)"]) 481 482 for testsuite_name, failures in failures.items(): 483 report.extend(["", f"### {testsuite_name}"]) 484 for name, output in failures: 485 report.extend( 486 [ 487 "<details>", 488 f"<summary>{name}</summary>", 489 "", 490 "```", 491 output, 492 "```", 493 "</details>", 494 ] 495 ) 496 elif return_code != 0: 497 # No tests failed but the build was in a failed state. Bring this to the user's 498 # attention. 499 report.extend( 500 [ 501 "", 502 "All tests passed but another part of the build **failed**.", 503 "", 504 f"{download_text} the build's log file to see the details.", 505 ] 506 ) 507 508 report = "\n".join(report) 509 if len(report.encode("utf-8")) > size_limit: 510 return _generate_report( 511 title, 512 return_code, 513 junit_objects, 514 size_limit, 515 list_failures=False, 516 buildkite_info=buildkite_info, 517 ) 518 519 return report, style 520 521 522def generate_report(title, return_code, junit_files, buildkite_info): 523 return _generate_report( 524 title, 525 return_code, 526 [JUnitXml.fromfile(p) for p in junit_files], 527 buildkite_info=buildkite_info, 528 ) 529 530 531if __name__ == "__main__": 532 parser = argparse.ArgumentParser() 533 parser.add_argument( 534 "title", help="Title of the test report, without Markdown formatting." 535 ) 536 parser.add_argument("context", help="Annotation context to write to.") 537 parser.add_argument("return_code", help="The build's return code.", type=int) 538 parser.add_argument("junit_files", help="Paths to JUnit report files.", nargs="*") 539 args = parser.parse_args() 540 541 # All of these are required to build a link to download the log file. 542 env_var_names = [ 543 "BUILDKITE_ORGANIZATION_SLUG", 544 "BUILDKITE_PIPELINE_SLUG", 545 "BUILDKITE_BUILD_NUMBER", 546 "BUILDKITE_JOB_ID", 547 ] 548 buildkite_info = {k: v for k, v in os.environ.items() if k in env_var_names} 549 if len(buildkite_info) != len(env_var_names): 550 buildkite_info = None 551 552 report, style = generate_report( 553 args.title, args.return_code, args.junit_files, buildkite_info 554 ) 555 556 if report: 557 p = subprocess.Popen( 558 [ 559 "buildkite-agent", 560 "annotate", 561 "--context", 562 args.context, 563 "--style", 564 style, 565 ], 566 stdin=subprocess.PIPE, 567 stderr=subprocess.PIPE, 568 universal_newlines=True, 569 ) 570 571 # The report can be larger than the buffer for command arguments so we send 572 # it over stdin instead. 573 _, err = p.communicate(input=report) 574 if p.returncode: 575 raise RuntimeError(f"Failed to send report to buildkite-agent:\n{err}") 576