From nobody Sun Feb 8 02:21:50 2026 Received: from mail-pl1-f202.google.com (mail-pl1-f202.google.com [209.85.214.202]) (using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) (No client certificate requested) by smtp.subspace.kernel.org (Postfix) with ESMTPS id CFF9F27991E for ; Mon, 19 Jan 2026 07:34:28 +0000 (UTC) Authentication-Results: smtp.subspace.kernel.org; arc=none smtp.client-ip=209.85.214.202 ARC-Seal: i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1768808070; cv=none; b=J1NQK3PzebZaDG+D6mTr5p1GF3TbUjMhOX8qzD6HfgvSKfra7TR24YDYyB898KsqC+xv/ewtBoqiIkTgnkDS9kpNDRg7cj2FHqJAYWiLREcYzTJABq5RvWEgamuA3fiqTRaWHn2sTitad4hoDxjxC1hEYrXRl1r2wfrc00urS6U= ARC-Message-Signature: i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1768808070; c=relaxed/simple; bh=udKRukIADJ+OlxExOBoDd539goTziNEtiqDK8arDYCk=; h=Date:Mime-Version:Message-ID:Subject:From:To:Cc:Content-Type; b=RHFUxEKgkm92rdwxt4tfl24KsodxhOnneAgNcpeiDSGGRWZx834P2JcAJ3Kgwau3nUhwN47qpc2BqG+Pw7qUsweN9QAO41RIkocUrYP2vZr+TW+EcKeyX0GcSmAIvYlSwpZ3xRswLcGAC7nZgmr6ZMBZQPPuhli3OtKGTAfSe9Q= ARC-Authentication-Results: i=1; smtp.subspace.kernel.org; dmarc=pass (p=reject dis=none) header.from=google.com; spf=pass smtp.mailfrom=flex--davidgow.bounces.google.com; dkim=pass (2048-bit key) header.d=google.com header.i=@google.com header.b=xoqkRW0P; arc=none smtp.client-ip=209.85.214.202 Authentication-Results: smtp.subspace.kernel.org; dmarc=pass (p=reject dis=none) header.from=google.com Authentication-Results: smtp.subspace.kernel.org; spf=pass smtp.mailfrom=flex--davidgow.bounces.google.com Authentication-Results: smtp.subspace.kernel.org; dkim=pass (2048-bit key) header.d=google.com header.i=@google.com header.b="xoqkRW0P" Received: by mail-pl1-f202.google.com with SMTP id d9443c01a7336-2a31087af17so39554975ad.1 for ; Sun, 18 Jan 2026 23:34:28 -0800 (PST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=20230601; t=1768808068; x=1769412868; darn=vger.kernel.org; h=cc:to:from:subject:message-id:mime-version:date:from:to:cc:subject :date:message-id:reply-to; bh=yuTbrg9rNxI7eBLFcdHRWL8vG9tYu7EW+ZDjzShaDEk=; b=xoqkRW0P5AAf6Y3mc+AcdIaxpbejw8YQwrsA9nqDyGPdX7fJgHVmrlls8PSCXGq9lv WuBirIMT236qcrRcBuuCKTOt+BwQ84X8EwmFslchMax4jrj0dzBEOQN5KyhFbxQh/cta XBvdlcWnU/dDp6LNcBBo/y0+4TqeNllURLdRtr9ZGB6JSb3vNJbdxFXCtG54yGfLULWz 1V+xsfGmjtRxTPpiG56lcdkarNTxrVJFJxUohlitac5OTbce+N8+S0jnXDqqRtvaJymH WfYRvHi9HmSvoonKSlh/jXRJV2SSX7sQQdR2e3Pp8OEBHtrmKKQjhqIp0/5vXR11TJZh tJ8g== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1768808068; x=1769412868; h=cc:to:from:subject:message-id:mime-version:date:x-gm-message-state :from:to:cc:subject:date:message-id:reply-to; bh=yuTbrg9rNxI7eBLFcdHRWL8vG9tYu7EW+ZDjzShaDEk=; b=hcUT5OBvEtyqnJMdsrhWwEHmjGUWY/6gO35ENjCxZfs9ldqiI76+oJJKGKFwQEKna/ L+SQgOyDajOj/sXVn55SNqieUVF3G/s2zYNYL5UNfb8VRECbIerI3dce7hGZavLTPHIy MSBOqwVrX2IQLp3WVjs7SkUvuKk+aUu6IVp0sEFXywC8i01dqVkuAH7ciz9sTd8zqZ+M vCs5wFA9AwBKSLWSF/hBWoyn0aiySPWlYoc7Ee8aZ6WhSXXXo6h0gk4oG5o2RzUaLzVY aW6u0w1plV08aDY3eWj++/I1SKJ2FLbTxHBL5g8OPdBZAjXhwMmjG4x+PBfus2W3IJOx f2HA== X-Forwarded-Encrypted: i=1; AJvYcCW1Dx71XavVXqNLk7EqZI5kojUE7V5i1EJVWa7+yllcx7V3TiTt2xE8vVqGibU9VBUbpZGGdsO9IhxIbx4=@vger.kernel.org X-Gm-Message-State: AOJu0YzYh7D5vmBrXHM7Rqvq+bai1WBVyCxlVE5f1yEaeEcA3TsIEja7 +kCcd+trIM1BnI4KgKKfbUMkHogIGAIRmTkB5e5p66nlk9Teauwt7XZekmiAsF/uUNdiUxHca7Z WETsuHxsq2pcaJQ== X-Received: from plwg14.prod.google.com ([2002:a17:902:f74e:b0:2a0:bb0f:ea50]) (user=davidgow job=prod-delivery.src-stubby-dispatcher) by 2002:a17:902:cccf:b0:298:1288:e873 with SMTP id d9443c01a7336-2a7177e7cc2mr92749315ad.56.1768808068198; Sun, 18 Jan 2026 23:34:28 -0800 (PST) Date: Mon, 19 Jan 2026 15:34:24 +0800 Precedence: bulk X-Mailing-List: linux-kernel@vger.kernel.org List-Id: List-Subscribe: List-Unsubscribe: Mime-Version: 1.0 X-Mailer: git-send-email 2.52.0.457.g6b5491de43-goog Message-ID: <20260119073426.1952867-1-davidgow@google.com> Subject: [PATCH] kunit: tool: Add (primitive) support for outputting JUnit XML From: David Gow To: Brendan Higgins , Rae Moar , Shuah Khan Cc: David Gow , linux-kselftest@vger.kernel.org, workflows@vger.kernel.org, linux-kernel@vger.kernel.org, kunit-dev@googlegroups.com Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset="utf-8" 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 injest it and it is already useful. Signed-off-by: David Gow --- Documentation/dev-tools/kunit/run_wrapper.rst | 3 ++ tools/testing/kunit/kunit.py | 25 +++++++++++- tools/testing/kunit/kunit_junit.py | 36 +++++++++++++++++ tools/testing/kunit/kunit_tool_test.py | 40 +++++++++++++++++-- 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tools/testing/kunit/kunit_junit.py diff --git a/Documentation/dev-tools/kunit/run_wrapper.rst b/Documentation/= dev-tools/kunit/run_wrapper.rst index 6697c71ee8ca..e5c318162581 100644 --- a/Documentation/dev-tools/kunit/run_wrapper.rst +++ b/Documentation/dev-tools/kunit/run_wrapper.rst @@ -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. =20 +- ``--junit``: If set, stores the test results in JUnit XML format and pri= nts to `stdout` or + saves to a file if a filename is specified. + - ``--filter``: Specifies filters on test attributes, for example, ``speed= !=3Dslow``. Multiple filters can be used by wrapping input in quotes and separating = filters by commas. Example: ``--filter "speed>slow, module=3Dexample"``. diff --git a/tools/testing/kunit/kunit.py b/tools/testing/kunit/kunit.py index e3d82a038f93..0698d27c3629 100755 --- a/tools/testing/kunit/kunit.py +++ b/tools/testing/kunit/kunit.py @@ -21,6 +21,7 @@ from enum import Enum, auto from typing import Iterable, List, Optional, Sequence, Tuple =20 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 =20 @@ -261,6 +263,17 @@ def parse_tests(request: KunitParseRequest, metadata: = kunit_json.Metadata, input stdout.print_with_timestamp("Test results stored in %s" % os.path.abspath(request.json)) =20 + if request.junit: + junit_str =3D kunit_junit.get_junit_result( + test=3Dtest) + if request.junit =3D=3D 'stdout': + print(junit_str) + else: + with open(request.junit, 'w') as f: + f.write(junit_str) + stdout.print_with_timestamp("Test results stored in %s" % + os.path.abspath(request.junit)) + if test.status !=3D kunit_parser.TestStatus.SUCCESS: return KunitResult(KunitStatus.TEST_FAILURE, parse_time), test =20 @@ -302,6 +315,7 @@ def run_tests(linux: kunit_kernel.LinuxSourceTree, # So we hackily automatically rewrite --json =3D> --json=3Dstdout pseudo_bool_flag_defaults =3D { '--json': 'stdout', + '--junit': 'stdout', '--raw_output': 'kunit', } def massage_argv(argv: Sequence[str]) -> Sequence[str]: @@ -436,6 +450,11 @@ def add_parse_opts(parser: argparse.ArgumentParser) ->= None: help=3D'Prints parsed test results as JSON to stdout or a file if ' 'a filename is specified. Does nothing if --raw_output is set.', type=3Dstr, const=3D'stdout', default=3DNone, metavar=3D'FILE') + parser.add_argument('--junit', + nargs=3D'?', + help=3D'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=3Dstr, const=3D'stdout', default=3DNone, metavar=3D'FILE') parser.add_argument('--summary', help=3D'Prints only the summary line for parsed test results.' 'Does nothing if --raw_output is set.', @@ -479,6 +498,7 @@ def run_handler(cli_args: argparse.Namespace) -> None: jobs=3Dcli_args.jobs, raw_output=3Dcli_args.raw_output, json=3Dcli_args.json, + junit=3Dcli_args.junit, summary=3Dcli_args.summary, failed=3Dcli_args.failed, timeout=3Dcli_args.timeout, @@ -528,6 +548,7 @@ def exec_handler(cli_args: argparse.Namespace) -> None: exec_request =3D KunitExecRequest(raw_output=3Dcli_args.raw_output, build_dir=3Dcli_args.build_dir, json=3Dcli_args.json, + junit=3Dcli_args.junit, summary=3Dcli_args.summary, failed=3Dcli_args.failed, timeout=3Dcli_args.timeout, @@ -555,7 +576,9 @@ def parse_handler(cli_args: argparse.Namespace) -> None: # We know nothing about how the result was created! metadata =3D kunit_json.Metadata() request =3D KunitParseRequest(raw_output=3Dcli_args.raw_output, - json=3Dcli_args.json, summary=3Dcli_args.summary, + json=3Dcli_args.json, + junit=3Dcli_args.junit, + summary=3Dcli_args.summary, failed=3Dcli_args.failed) result, _ =3D parse_tests(request, metadata, kunit_output) if result.status !=3D KunitStatus.SUCCESS: diff --git a/tools/testing/kunit/kunit_junit.py b/tools/testing/kunit/kunit= _junit.py new file mode 100644 index 000000000000..58d482e0c793 --- /dev/null +++ b/tools/testing/kunit/kunit_junit.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: GPL-2.0 +# +# Generates JSON from KUnit results according to +# KernelCI spec: https://github.com/kernelci/kernelci-doc/wiki/Test-API +# +# Copyright (C) 2025, Google LLC. +# Author: David Gow + + +from kunit_parser import Test, TestStatus + +def escape_xml_string(string : str) -> str: + return string.replace("&", "&").replace("\"", """).replace("'", = "'").replace("<", "<").replace(">", ">") + +def get_test_suite(test: Test) -> str: + xml_output =3D '\n' + + for subtest in test.subtests: + if subtest.subtests: + xml_output +=3D get_test_suite(subtest) + continue + xml_output +=3D 'Test Failed\n' + xml_output +=3D '<= /system-out>\n' + xml_output +=3D '\n' + + xml_output +=3D '\n\n' + + return xml_output + +def get_junit_result(test: Test) -> str: + xml_output =3D '\n\n' + + xml_output +=3D get_test_suite(test) + return xml_output diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/k= unit_tool_test.py index 238a31a5cc29..e29ef4162f9e 100755 --- a/tools/testing/kunit/kunit_tool_test.py +++ b/tools/testing/kunit/kunit_tool_test.py @@ -22,6 +22,7 @@ import kunit_config import kunit_parser import kunit_kernel import kunit_json +import kunit_junit import kunit from kunit_printer import stdout =20 @@ -606,6 +607,39 @@ class StrContains(str): def __eq__(self, other): return self in other =20 +class KUnitJUnitTest(unittest.TestCase): + def setUp(self): + self.print_mock =3D 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 =3D kunit_parser.parse_run_tests(file, stdout) + junit_string =3D kunit_junit.get_junit_result( + test=3Dtest_result) + return junit_string + + def test_xml_escape(self): + self.assertEqual(kunit_junit.escape_xml_string("qwertyuiop"), "qwertyuio= p") + self.assertEqual(kunit_junit.escape_xml_string("\"quoted\""), ""quo= ted"") + self.assertEqual(kunit_junit.escape_xml_string("'quoted'"), "'quote= d'") + self.assertEqual(kunit_junit.escape_xml_string(""), "<tag>") + self.assertEqual(kunit_junit.escape_xml_string("&"), "&amp;") + + def test_failed_test_junit(self): + result =3D self._junit_string('test_is_test_passed-failure.log') + self.assertTrue("" in result) + + def test_skipped_test_junit(self): + result =3D self._junit_string('test_skip_tests.log') + self.assertTrue("skipped=3D\"1\"" in result) + + def test_no_tests_junit(self): + result =3D self._junit_string('test_is_test_passed-no_tests_run_with_hea= der.log') + self.assertTrue("tests=3D\"0\"" in result) + self.assertFalse("testcase" in result) + + class KUnitMainTest(unittest.TestCase): def setUp(self): path =3D _test_data_path('test_is_test_passed-all_passed.log') @@ -853,7 +887,7 @@ class KUnitMainTest(unittest.TestCase): self.linux_source_mock.run_kernel.return_value =3D ['TAP version 14', 'i= nit: random output'] + want =20 got =3D kunit._list_tests(self.linux_source_mock, - kunit.KunitExecRequest(None, None, False, False, '.kunit', 300, '= suite*', '', None, None, 'suite', False, False)) + kunit.KunitExecRequest(None, None, None, False, False, '.kunit', = 300, 'suite*', '', None, None, 'suite', 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( @@ -866,7 +900,7 @@ class KUnitMainTest(unittest.TestCase): =20 # 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)) + kunit.KunitExecRequest(None, None, None, False, False, '.kunit', = 300, 'suite*.test*', '', None, None, 'suite', False, False)) self.linux_source_mock.run_kernel.assert_has_calls([ mock.call(args=3DNone, build_dir=3D'.kunit', filter_glob=3D'suite.test*= ', filter=3D'', filter_action=3DNone, timeout=3D300), mock.call(args=3DNone, build_dir=3D'.kunit', filter_glob=3D'suite2.test= *', filter=3D'', filter_action=3DNone, timeout=3D300), @@ -879,7 +913,7 @@ class KUnitMainTest(unittest.TestCase): =20 # 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)) + kunit.KunitExecRequest(None, None, None, False, False, '.kunit', = 300, 'suite*', '', None, None, 'test', False, False)) self.linux_source_mock.run_kernel.assert_has_calls([ mock.call(args=3DNone, build_dir=3D'.kunit', filter_glob=3D'suite.test1= ', filter=3D'', filter_action=3DNone, timeout=3D300), mock.call(args=3DNone, build_dir=3D'.kunit', filter_glob=3D'suite.test2= ', filter=3D'', filter_action=3DNone, timeout=3D300), --=20 2.52.0.457.g6b5491de43-goog