mirror of
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
synced 2026-07-22 03:27:30 -04:00
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>
This commit is contained in:
@@ -324,6 +324,9 @@ command line arguments:
|
||||
- ``--json``: If set, stores the test results in a JSON format and prints to `stdout` or
|
||||
saves to a file if a filename is specified.
|
||||
|
||||
- ``--junit``: If set, stores the test results in JUnit XML format and prints to `stdout` or
|
||||
saves to a file if a filename is specified.
|
||||
|
||||
- ``--filter``: Specifies filters on test attributes, for example, ``speed!=slow``.
|
||||
Multiple filters can be used by wrapping input in quotes and separating filters
|
||||
by commas. Example: ``--filter "speed>slow, module=example"``.
|
||||
|
||||
@@ -21,6 +21,7 @@ from enum import Enum, auto
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import kunit_json
|
||||
import kunit_junit
|
||||
import kunit_kernel
|
||||
import kunit_parser
|
||||
from kunit_printer import stdout, null_printer
|
||||
@@ -49,6 +50,7 @@ class KunitBuildRequest(KunitConfigRequest):
|
||||
class KunitParseRequest:
|
||||
raw_output: Optional[str]
|
||||
json: Optional[str]
|
||||
junit: Optional[str]
|
||||
summary: bool
|
||||
failed: bool
|
||||
|
||||
@@ -268,6 +270,13 @@ def parse_tests(request: KunitParseRequest, metadata: kunit_json.Metadata, input
|
||||
stdout.print_with_timestamp("Test results stored in %s" %
|
||||
os.path.abspath(request.json))
|
||||
|
||||
if request.junit:
|
||||
if request.junit == 'stdout':
|
||||
kunit_junit.print_junit_result(test=test)
|
||||
else:
|
||||
kunit_junit.write_junit_result(test=test,filename=request.junit)
|
||||
stdout.print_with_timestamp(f"Test results stored in {os.path.abspath(request.junit)}")
|
||||
|
||||
if test.status != kunit_parser.TestStatus.SUCCESS:
|
||||
return KunitResult(KunitStatus.TEST_FAILURE, parse_time), test
|
||||
|
||||
@@ -309,6 +318,7 @@ def run_tests(linux: kunit_kernel.LinuxSourceTree,
|
||||
# So we hackily automatically rewrite --json => --json=stdout
|
||||
pseudo_bool_flag_defaults = {
|
||||
'--json': 'stdout',
|
||||
'--junit': 'stdout',
|
||||
'--raw_output': 'kunit',
|
||||
}
|
||||
def massage_argv(argv: Sequence[str]) -> Sequence[str]:
|
||||
@@ -459,6 +469,11 @@ def add_parse_opts(parser: argparse.ArgumentParser) -> None:
|
||||
help='Prints parsed test results as JSON to stdout or a file if '
|
||||
'a filename is specified. Does nothing if --raw_output is set.',
|
||||
type=str, const='stdout', default=None, metavar='FILE')
|
||||
parser.add_argument('--junit',
|
||||
nargs='?',
|
||||
help='Prints parsed test results as JUnit XML to stdout or a file if '
|
||||
'a filename is specified. Does nothing if --raw_output is set.',
|
||||
type=str, const='stdout', default=None, metavar='FILE')
|
||||
parser.add_argument('--summary',
|
||||
help='Prints only the summary line for parsed test results.'
|
||||
'Does nothing if --raw_output is set.',
|
||||
@@ -502,6 +517,7 @@ def run_handler(cli_args: argparse.Namespace) -> None:
|
||||
jobs=cli_args.jobs,
|
||||
raw_output=cli_args.raw_output,
|
||||
json=cli_args.json,
|
||||
junit=cli_args.junit,
|
||||
summary=cli_args.summary,
|
||||
failed=cli_args.failed,
|
||||
timeout=cli_args.timeout,
|
||||
@@ -552,6 +568,7 @@ def exec_handler(cli_args: argparse.Namespace) -> None:
|
||||
exec_request = KunitExecRequest(raw_output=cli_args.raw_output,
|
||||
build_dir=cli_args.build_dir,
|
||||
json=cli_args.json,
|
||||
junit=cli_args.junit,
|
||||
summary=cli_args.summary,
|
||||
failed=cli_args.failed,
|
||||
timeout=cli_args.timeout,
|
||||
@@ -580,7 +597,9 @@ def parse_handler(cli_args: argparse.Namespace) -> None:
|
||||
# We know nothing about how the result was created!
|
||||
metadata = kunit_json.Metadata()
|
||||
request = KunitParseRequest(raw_output=cli_args.raw_output,
|
||||
json=cli_args.json, summary=cli_args.summary,
|
||||
json=cli_args.json,
|
||||
junit=cli_args.junit,
|
||||
summary=cli_args.summary,
|
||||
failed=cli_args.failed)
|
||||
result, _ = parse_tests(request, metadata, kunit_output)
|
||||
if result.status != KunitStatus.SUCCESS:
|
||||
|
||||
61
tools/testing/kunit/kunit_junit.py
Normal file
61
tools/testing/kunit/kunit_junit.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
#
|
||||
# Generates JUnit XML files from KUnit test results
|
||||
#
|
||||
# Copyright (C) 2026, Google LLC and David Gow.
|
||||
|
||||
from xml.sax.saxutils import quoteattr, XMLGenerator
|
||||
import xml.etree.ElementTree as ET
|
||||
from kunit_parser import Test, TestStatus
|
||||
from typing import Optional
|
||||
|
||||
# Get a string representing a tes suite (including subtests) in JUnit XML
|
||||
def get_test_suite(test: Test, parent: Optional[ET.Element]) -> ET.Element:
|
||||
suite_attrs = {
|
||||
'name': test.name,
|
||||
'tests': str(test.counts.total()),
|
||||
'failures': str(test.counts.failed),
|
||||
'skipped': str(test.counts.skipped),
|
||||
'errors': str(test.counts.crashed + test.counts.errors),
|
||||
}
|
||||
|
||||
if parent is not None:
|
||||
test_suite_element = ET.SubElement(parent, 'testsuite', suite_attrs)
|
||||
else:
|
||||
test_suite_element = ET.Element('testsuite', suite_attrs)
|
||||
|
||||
for subtest in test.subtests:
|
||||
if subtest.subtests:
|
||||
get_test_suite(subtest, test_suite_element)
|
||||
continue
|
||||
test_case_element = ET.SubElement(test_suite_element, 'testcase', {'name': subtest.name})
|
||||
if subtest.status == TestStatus.FAILURE:
|
||||
ET.SubElement(test_case_element, 'failure', {}).text = 'Test Failed'
|
||||
elif subtest.status == TestStatus.SKIPPED:
|
||||
ET.SubElement(test_case_element, 'skipped', {}).text = subtest.skip_reason
|
||||
elif subtest.status == TestStatus.TEST_CRASHED:
|
||||
ET.SubElement(test_case_element, 'error', {}).text = 'Test Crashed'
|
||||
|
||||
if subtest.log:
|
||||
ET.SubElement(test_case_element, 'system-out', {}).text = "\n".join(subtest.log)
|
||||
|
||||
return test_suite_element
|
||||
|
||||
# Get a string for an entire XML file for the test structure starting at test
|
||||
def get_junit_result(test: Test) -> str:
|
||||
root_element = get_test_suite(test, None)
|
||||
ET.indent(root_element)
|
||||
return ET.tostring(root_element, encoding="unicode", xml_declaration=True)
|
||||
|
||||
# Print a JUnit result to stdout.
|
||||
def print_junit_result(test: Test) -> None:
|
||||
root_element = get_test_suite(test, None)
|
||||
ET.indent(root_element)
|
||||
ET.dump(root_element)
|
||||
|
||||
# Write an entire XML file for the test structure starting at test
|
||||
def write_junit_result(test: Test, filename: str) -> None:
|
||||
root_element = get_test_suite(test, None)
|
||||
ET.indent(root_element)
|
||||
root_et = ET.ElementTree(root_element)
|
||||
root_et.write(filename, encoding='utf-8', xml_declaration=True)
|
||||
@@ -24,6 +24,7 @@ import kunit_config
|
||||
import kunit_parser
|
||||
import kunit_kernel
|
||||
import kunit_json
|
||||
import kunit_junit
|
||||
import kunit
|
||||
from kunit_printer import stdout
|
||||
|
||||
@@ -693,6 +694,38 @@ class StrContains(str):
|
||||
def __eq__(self, other):
|
||||
return self in other
|
||||
|
||||
class KUnitJUnitTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.print_mock = mock.patch('kunit_printer.Printer.print').start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def _junit_string(self, log_file):
|
||||
with open(_test_data_path(log_file)) as file:
|
||||
test_result = kunit_parser.parse_run_tests(file, stdout)
|
||||
junit_string = kunit_junit.get_junit_result(
|
||||
test=test_result)
|
||||
print(junit_string)
|
||||
return junit_string
|
||||
|
||||
def test_failed_test_junit(self):
|
||||
result = self._junit_string('test_is_test_passed-failure.log')
|
||||
self.assertTrue("<failure>" in result)
|
||||
|
||||
def test_skipped_test_junit(self):
|
||||
result = self._junit_string('test_skip_tests.log')
|
||||
self.assertTrue("<skipped>" in result)
|
||||
self.assertTrue("skipped=\"1\"" in result)
|
||||
|
||||
def test_crashed_test_junit(self):
|
||||
result = self._junit_string('test_kernel_panic_interrupt.log')
|
||||
self.assertTrue("<error>" in result);
|
||||
|
||||
def test_no_tests_junit(self):
|
||||
result = self._junit_string('test_is_test_passed-no_tests_run_with_header.log')
|
||||
self.assertTrue("tests=\"0\"" in result)
|
||||
self.assertFalse("testcase" in result)
|
||||
|
||||
|
||||
class KUnitMainTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
path = _test_data_path('test_is_test_passed-all_passed.log')
|
||||
@@ -940,7 +973,7 @@ class KUnitMainTest(unittest.TestCase):
|
||||
self.linux_source_mock.run_kernel.return_value = ['TAP version 14', 'init: random output'] + want
|
||||
|
||||
got = kunit._list_tests(self.linux_source_mock,
|
||||
kunit.KunitExecRequest(None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'suite', False, False, False))
|
||||
kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'suite', False, False, False))
|
||||
self.assertEqual(got, want)
|
||||
# Should respect the user's filter glob when listing tests.
|
||||
self.linux_source_mock.run_kernel.assert_called_once_with(
|
||||
@@ -953,7 +986,7 @@ class KUnitMainTest(unittest.TestCase):
|
||||
|
||||
# Should respect the user's filter glob when listing tests.
|
||||
mock_tests.assert_called_once_with(mock.ANY,
|
||||
kunit.KunitExecRequest(None, None, False, False, '.kunit', 300, 'suite*.test*', '', None, None, 'suite', False, False, False))
|
||||
kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*.test*', '', None, None, 'suite', False, False, False))
|
||||
self.linux_source_mock.run_kernel.assert_has_calls([
|
||||
mock.call(args=None, build_dir='.kunit', filter_glob='suite.test*', filter='', filter_action=None, timeout=300),
|
||||
mock.call(args=None, build_dir='.kunit', filter_glob='suite2.test*', filter='', filter_action=None, timeout=300),
|
||||
@@ -966,7 +999,7 @@ class KUnitMainTest(unittest.TestCase):
|
||||
|
||||
# Should respect the user's filter glob when listing tests.
|
||||
mock_tests.assert_called_once_with(mock.ANY,
|
||||
kunit.KunitExecRequest(None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'test', False, False, False))
|
||||
kunit.KunitExecRequest(None, None, None, False, False, '.kunit', 300, 'suite*', '', None, None, 'test', False, False, False))
|
||||
self.linux_source_mock.run_kernel.assert_has_calls([
|
||||
mock.call(args=None, build_dir='.kunit', filter_glob='suite.test1', filter='', filter_action=None, timeout=300),
|
||||
mock.call(args=None, build_dir='.kunit', filter_glob='suite.test2', filter='', filter_action=None, timeout=300),
|
||||
|
||||
Reference in New Issue
Block a user