Skip to content

Commit e9e05c7

Browse files
sulixshuahkh
authored andcommitted
kunit: tool: Add (primitive) support for outputting JUnit XML
This is used by things like Jenkins and other CI systems, which can pretty-print the test output and potentially provide test-level comparisons between runs. The implementation here is pretty basic: it only provides the raw results, split into tests and test suites, and doesn't provide any overall metadata. However, CI systems like Jenkins can ingest it and it is already useful. Link: https://lore.kernel.org/r/20260606013827.240790-2-david@davidgow.net Reviewed-by: Thomas Weißschuh <thomas.weissschuh@linutronix.de> Signed-off-by: David Gow <david@davidgow.net> Signed-off-by: Shuah Khan <skhan@linuxfoundation.org>
1 parent bfd73e0 commit e9e05c7

4 files changed

Lines changed: 120 additions & 4 deletions

File tree

Documentation/dev-tools/kunit/run_wrapper.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@ command line arguments:
324324
- ``--json``: If set, stores the test results in a JSON format and prints to `stdout` or
325325
saves to a file if a filename is specified.
326326

327+
- ``--junit``: If set, stores the test results in JUnit XML format and prints to `stdout` or
328+
saves to a file if a filename is specified.
329+
327330
- ``--filter``: Specifies filters on test attributes, for example, ``speed!=slow``.
328331
Multiple filters can be used by wrapping input in quotes and separating filters
329332
by commas. Example: ``--filter "speed>slow, module=example"``.

tools/testing/kunit/kunit.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from typing import Iterable, List, Optional, Sequence, Tuple
2222

2323
import kunit_json
24+
import kunit_junit
2425
import kunit_kernel
2526
import kunit_parser
2627
from kunit_printer import stdout, null_printer
@@ -49,6 +50,7 @@ class KunitBuildRequest(KunitConfigRequest):
4950
class KunitParseRequest:
5051
raw_output: Optional[str]
5152
json: Optional[str]
53+
junit: Optional[str]
5254
summary: bool
5355
failed: bool
5456

@@ -268,6 +270,13 @@ def parse_tests(request: KunitParseRequest, metadata: kunit_json.Metadata, input
268270
stdout.print_with_timestamp("Test results stored in %s" %
269271
os.path.abspath(request.json))
270272

273+
if request.junit:
274+
if request.junit == 'stdout':
275+
kunit_junit.print_junit_result(test=test)
276+
else:
277+
kunit_junit.write_junit_result(test=test,filename=request.junit)
278+
stdout.print_with_timestamp(f"Test results stored in {os.path.abspath(request.junit)}")
279+
271280
if test.status != kunit_parser.TestStatus.SUCCESS:
272281
return KunitResult(KunitStatus.TEST_FAILURE, parse_time), test
273282

@@ -309,6 +318,7 @@ def run_tests(linux: kunit_kernel.LinuxSourceTree,
309318
# So we hackily automatically rewrite --json => --json=stdout
310319
pseudo_bool_flag_defaults = {
311320
'--json': 'stdout',
321+
'--junit': 'stdout',
312322
'--raw_output': 'kunit',
313323
}
314324
def massage_argv(argv: Sequence[str]) -> Sequence[str]:
@@ -459,6 +469,11 @@ def add_parse_opts(parser: argparse.ArgumentParser) -> None:
459469
help='Prints parsed test results as JSON to stdout or a file if '
460470
'a filename is specified. Does nothing if --raw_output is set.',
461471
type=str, const='stdout', default=None, metavar='FILE')
472+
parser.add_argument('--junit',
473+
nargs='?',
474+
help='Prints parsed test results as JUnit XML to stdout or a file if '
475+
'a filename is specified. Does nothing if --raw_output is set.',
476+
type=str, const='stdout', default=None, metavar='FILE')
462477
parser.add_argument('--summary',
463478
help='Prints only the summary line for parsed test results.'
464479
'Does nothing if --raw_output is set.',
@@ -502,6 +517,7 @@ def run_handler(cli_args: argparse.Namespace) -> None:
502517
jobs=cli_args.jobs,
503518
raw_output=cli_args.raw_output,
504519
json=cli_args.json,
520+
junit=cli_args.junit,
505521
summary=cli_args.summary,
506522
failed=cli_args.failed,
507523
timeout=cli_args.timeout,
@@ -552,6 +568,7 @@ def exec_handler(cli_args: argparse.Namespace) -> None:
552568
exec_request = KunitExecRequest(raw_output=cli_args.raw_output,
553569
build_dir=cli_args.build_dir,
554570
json=cli_args.json,
571+
junit=cli_args.junit,
555572
summary=cli_args.summary,
556573
failed=cli_args.failed,
557574
timeout=cli_args.timeout,
@@ -580,7 +597,9 @@ def parse_handler(cli_args: argparse.Namespace) -> None:
580597
# We know nothing about how the result was created!
581598
metadata = kunit_json.Metadata()
582599
request = KunitParseRequest(raw_output=cli_args.raw_output,
583-
json=cli_args.json, summary=cli_args.summary,
600+
json=cli_args.json,
601+
junit=cli_args.junit,
602+
summary=cli_args.summary,
584603
failed=cli_args.failed)
585604
result, _ = parse_tests(request, metadata, kunit_output)
586605
if result.status != KunitStatus.SUCCESS:

tools/testing/kunit/kunit_junit.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# SPDX-License-Identifier: GPL-2.0
2+
#
3+
# Generates JUnit XML files from KUnit test results
4+
#
5+
# Copyright (C) 2026, Google LLC and David Gow.
6+
7+
from xml.sax.saxutils import quoteattr, XMLGenerator
8+
import xml.etree.ElementTree as ET
9+
from kunit_parser import Test, TestStatus
10+
from typing import Optional
11+
12+
# Get a string representing a tes suite (including subtests) in JUnit XML
13+
def get_test_suite(test: Test, parent: Optional[ET.Element]) -> ET.Element:
14+
suite_attrs = {
15+
'name': test.name,
16+
'tests': str(test.counts.total()),
17+
'failures': str(test.counts.failed),
18+
'skipped': str(test.counts.skipped),
19+
'errors': str(test.counts.crashed + test.counts.errors),
20+
}
21+
22+
if parent is not None:
23+
test_suite_element = ET.SubElement(parent, 'testsuite', suite_attrs)
24+
else:
25+
test_suite_element = ET.Element('testsuite', suite_attrs)
26+
27+
for subtest in test.subtests:
28+
if subtest.subtests:
29+
get_test_suite(subtest, test_suite_element)
30+
continue
31+
test_case_element = ET.SubElement(test_suite_element, 'testcase', {'name': subtest.name})
32+
if subtest.status == TestStatus.FAILURE:
33+
ET.SubElement(test_case_element, 'failure', {}).text = 'Test Failed'
34+
elif subtest.status == TestStatus.SKIPPED:
35+
ET.SubElement(test_case_element, 'skipped', {}).text = subtest.skip_reason
36+
elif subtest.status == TestStatus.TEST_CRASHED:
37+
ET.SubElement(test_case_element, 'error', {}).text = 'Test Crashed'
38+
39+
if subtest.log:
40+
ET.SubElement(test_case_element, 'system-out', {}).text = "\n".join(subtest.log)
41+
42+
return test_suite_element
43+
44+
# Get a string for an entire XML file for the test structure starting at test
45+
def get_junit_result(test: Test) -> str:
46+
root_element = get_test_suite(test, None)
47+
ET.indent(root_element)
48+
return ET.tostring(root_element, encoding="unicode", xml_declaration=True)
49+
50+
# Print a JUnit result to stdout.
51+
def print_junit_result(test: Test) -> None:
52+
root_element = get_test_suite(test, None)
53+
ET.indent(root_element)
54+
ET.dump(root_element)
55+
56+
# Write an entire XML file for the test structure starting at test
57+
def write_junit_result(test: Test, filename: str) -> None:
58+
root_element = get_test_suite(test, None)
59+
ET.indent(root_element)
60+
root_et = ET.ElementTree(root_element)
61+
root_et.write(filename, encoding='utf-8', xml_declaration=True)

tools/testing/kunit/kunit_tool_test.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import kunit_parser
2525
import kunit_kernel
2626
import kunit_json
27+
import kunit_junit
2728
import kunit
2829
from kunit_printer import stdout
2930

@@ -693,6 +694,38 @@ class StrContains(str):
693694
def __eq__(self, other):
694695
return self in other
695696

697+
class KUnitJUnitTest(unittest.TestCase):
698+
def setUp(self):
699+
self.print_mock = mock.patch('kunit_printer.Printer.print').start()
700+
self.addCleanup(mock.patch.stopall)
701+
702+
def _junit_string(self, log_file):
703+
with open(_test_data_path(log_file)) as file:
704+
test_result = kunit_parser.parse_run_tests(file, stdout)
705+
junit_string = kunit_junit.get_junit_result(
706+
test=test_result)
707+
print(junit_string)
708+
return junit_string
709+
710+
def test_failed_test_junit(self):
711+
result = self._junit_string('test_is_test_passed-failure.log')
712+
self.assertTrue("<failure>" in result)
713+
714+
def test_skipped_test_junit(self):
715+
result = self._junit_string('test_skip_tests.log')
716+
self.assertTrue("<skipped>" in result)
717+
self.assertTrue("skipped=\"1\"" in result)
718+
719+
def test_crashed_test_junit(self):
720+
result = self._junit_string('test_kernel_panic_interrupt.log')
721+
self.assertTrue("<error>" in result);
722+
723+
def test_no_tests_junit(self):
724+
result = self._junit_string('test_is_test_passed-no_tests_run_with_header.log')
725+
self.assertTrue("tests=\"0\"" in result)
726+
self.assertFalse("testcase" in result)
727+
728+
696729
class KUnitMainTest(unittest.TestCase):
697730
def setUp(self):
698731
path = _test_data_path('test_is_test_passed-all_passed.log')
@@ -940,7 +973,7 @@ def test_list_tests(self):
940973
self.linux_source_mock.run_kernel.return_value = ['TAP version 14', 'init: random output'] + want
941974

942975
got = kunit._list_tests(self.linux_source_mock,
943-
kunit.KunitExecRequest(None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'suite', False, False, False))
976+
kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'suite', False, False, False))
944977
self.assertEqual(got, want)
945978
# Should respect the user's filter glob when listing tests.
946979
self.linux_source_mock.run_kernel.assert_called_once_with(
@@ -953,7 +986,7 @@ def test_run_isolated_by_suite(self, mock_tests):
953986

954987
# Should respect the user's filter glob when listing tests.
955988
mock_tests.assert_called_once_with(mock.ANY,
956-
kunit.KunitExecRequest(None, None, False, False, '.kunit', 300, 'suite*.test*', '', None, None, 'suite', False, False, False))
989+
kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*.test*', '', None, None, 'suite', False, False, False))
957990
self.linux_source_mock.run_kernel.assert_has_calls([
958991
mock.call(args=None, build_dir='.kunit', filter_glob='suite.test*', filter='', filter_action=None, timeout=300),
959992
mock.call(args=None, build_dir='.kunit', filter_glob='suite2.test*', filter='', filter_action=None, timeout=300),
@@ -966,7 +999,7 @@ def test_run_isolated_by_test(self, mock_tests):
966999

9671000
# Should respect the user's filter glob when listing tests.
9681001
mock_tests.assert_called_once_with(mock.ANY,
969-
kunit.KunitExecRequest(None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'test', False, False, False))
1002+
kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'test', False, False, False))
9701003
self.linux_source_mock.run_kernel.assert_has_calls([
9711004
mock.call(args=None, build_dir='.kunit', filter_glob='suite.test1', filter='', filter_action=None, timeout=300),
9721005
mock.call(args=None, build_dir='.kunit', filter_glob='suite.test2', filter='', filter_action=None, timeout=300),

0 commit comments

Comments
 (0)