Repository: weiwei/junitparser

Issue:
Add tests of the CLI
Currently there are no tests of the command line interface usage of junitparser. They would be good to have to show potential problems after modifications. Eg. #78 was a case that might have been avoided by having tests covering the CLI usage.

Discussion:
@weiwei: Agreed. I'll see what I can do.

Solution patch:
diff --git a/junitparser/cli.py b/junitparser/cli.py
index da5f7d2..0695a93 100644
--- a/junitparser/cli.py
+++ b/junitparser/cli.py
@@ -6,8 +6,8 @@ from itertools import chain
 from . import JUnitXml, version
 
 
-def merge(paths, output, suite_name):
-    """Merge XML report."""
+def merge(paths, output, suite_name=""):
+    """Merge XML reports."""
     result = JUnitXml()
     for path in paths:
         result += JUnitXml.fromfile(path)
@@ -44,18 +44,23 @@ def _parser(prog_name=None):  # pragma: no cover
     command_parser = parser.add_subparsers(dest="command", help="command")
     command_parser.required = True
 
-    # command: merge
-    merge_parser = command_parser.add_parser(
-        "merge", help="Merge JUnit XML format reports with junitparser."
-    )
-    merge_parser.add_argument(
+    # an abstract object that defines common arguments used by multiple commands
+    abstract_parser = ArgumentParser(add_help=False)
+    abstract_parser.add_argument(
         "--glob",
         help="Treat original XML path(s) as glob(s).",
         dest="paths_are_globs",
         action="store_true",
         default=False,
     )
-    merge_parser.add_argument("paths", nargs="+", help="Original XML path(s).")
+    abstract_parser.add_argument("paths", help="Original XML path(s).", nargs="+")
+
+    # command: merge
+    merge_parser = command_parser.add_parser(
+        "merge",
+        help="Merge JUnit XML format reports with junitparser.",
+        parents=[abstract_parser],
+    )
     merge_parser.add_argument(
         "output", help='Merged XML Path, setting to "-" will output to the console'
     )
@@ -65,19 +70,10 @@ def _parser(prog_name=None):  # pragma: no cover
     )
 
     # command: verify
-    merge_parser = command_parser.add_parser(
+    verify_parser = command_parser.add_parser(  # noqa: F841
         "verify",
         help="Return a non-zero exit code if one of the testcases failed or errored.",
-    )
-    merge_parser.add_argument(
-        "--glob",
-        help="Treat original XML path(s) as glob(s).",
-        dest="paths_are_globs",
-        action="store_true",
-        default=False,
-    )
-    merge_parser.add_argument(
-        "paths", nargs="+", help="XML path(s) of reports to verify."
+        parents=[abstract_parser],
     )
 
     return parser
@@ -85,19 +81,14 @@ def _parser(prog_name=None):  # pragma: no cover
 
 def main(args=None, prog_name=None):
     """CLI's main runner."""
-    args = args or _parser(prog_name=prog_name).parse_args()
+    args = _parser(prog_name=prog_name).parse_args(args)
+    paths = (
+        chain.from_iterable(iglob(path) for path in args.paths)
+        if args.paths_are_globs
+        else args.paths
+    )
     if args.command == "merge":
-        return merge(
-            chain.from_iterable(iglob(path) for path in args.paths)
-            if args.paths_are_globs
-            else args.paths,
-            args.output,
-            args.suite_name,
-        )
+        return merge(paths, args.output, args.suite_name)
     if args.command == "verify":
-        return verify(
-            chain.from_iterable(iglob(path) for path in args.paths)
-            if args.paths_are_globs
-            else args.paths
-        )
+        return verify(paths)
     return 255


Test patch:
diff --git a/tests/data/pytest_error.xml b/tests/data/pytest_error.xml
new file mode 100644
index 0000000..a3b9eef
--- /dev/null
+++ b/tests/data/pytest_error.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<testsuites>
+	<testsuite name="pytest" errors="0" failures="1" skipped="0" tests="1" time="0.039" timestamp="2025-01-14T20:33:43.564504+01:00">
+		<testcase classname="tests.test_cli" name="test_merge" time="0.001">
+			<failure message="NameError: name 'file' is not defined" />
+		</testcase>
+	</testsuite>
+</testsuites>
diff --git a/tests/data/pytest_success.xml b/tests/data/pytest_success.xml
new file mode 100644
index 0000000..8e15e4d
--- /dev/null
+++ b/tests/data/pytest_success.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<testsuites>
+	<testsuite name="pytest" errors="0" failures="0" skipped="0" tests="1" time="0.026" timestamp="2025-01-14T20:33:46.249864+01:00" >
+		<testcase classname="tests.test_fromfile" name="test_fromfile" time="0.001" />
+	</testsuite>
+</testsuites>
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 3b2553d..89e8f91 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,12 +1,107 @@
-import os
+from pathlib import Path
 import pytest
-from junitparser.cli import verify
+from junitparser import cli
+from junitparser import version
+
+DATA_DIR = Path(__file__).parent / "data"
 
 
 @pytest.mark.parametrize(
     "file, expected_exitcode",
-    [("data/jenkins.xml", 1), ("data/no_fails.xml", 0), ("data/normal.xml", 1)],
+    [("jenkins.xml", 1), ("no_fails.xml", 0), ("normal.xml", 1)],
 )
-def test_verify(file, expected_exitcode):
-    path = os.path.join(os.path.dirname(__file__), file)
-    assert verify([path]) == expected_exitcode
+def test_verify(file: str, expected_exitcode: int):
+    path = DATA_DIR / file
+    assert cli.verify([path]) == expected_exitcode
+
+
+def test_merge(tmp_path: Path):
+    files = [DATA_DIR / "jenkins.xml", DATA_DIR / "pytest_success.xml"]
+    suites = ["JUnitXmlReporter", "JUnitXmlReporter.constructor", "pytest"]
+    outfile = tmp_path / "merged.xml"
+    cli.merge(files, str(outfile))
+    xml = outfile.read_text()
+    for s in suites:
+        assert f'name="{s}"' in xml
+
+
+def test_merge_output_to_terminal(capsys: pytest.CaptureFixture):
+    ret = cli.main(["merge", str(DATA_DIR / "normal.xml"), "-"])
+    assert ret == 0
+    captured = capsys.readouterr()
+    assert captured.out.startswith("<?xml version='1.0'")
+
+
+def test_verify_with_glob():
+    ret = cli.main(["verify", "--glob", str(DATA_DIR / "pytest_*.xml")])
+    # we expect failure, as one of the files has errors
+    assert ret == 1
+
+
+class Test_CommandlineOptions:
+
+    @classmethod
+    def setup_class(cls):
+        cls.parser = cli._parser("junitparser")
+
+    @pytest.mark.parametrize("arg", ["-v", "--version"])
+    def test_version(self, arg, capsys):
+        with pytest.raises(SystemExit) as e:
+            self.parser.parse_args([arg])
+        captured = capsys.readouterr()
+        assert e.value.code == 0
+        assert captured.out == f"junitparser {version}\n"
+
+    def test_help_shows_subcommands(self, capsys):
+        with pytest.raises(SystemExit) as e:
+            self.parser.parse_args(["--help"])
+        captured = capsys.readouterr()
+        assert "{merge,verify} ...\n" in captured.out
+        assert e.value.code == 0
+
+    @pytest.mark.parametrize("command", ["merge", "verify"])
+    def test_subcommand_help(self, command):
+        with pytest.raises(SystemExit) as e:
+            self.parser.parse_args([command, "--help"])
+        assert e.value.code == 0
+
+    @pytest.mark.parametrize("command", ["merge", "verify"])
+    def test_subcommands_help_general_options(self, command, capsys):
+        with pytest.raises(SystemExit):
+            self.parser.parse_args([command, "--help"])
+        captured = capsys.readouterr()
+        assert "[--glob]" in captured.out
+        assert "paths [paths ...]" in captured.out
+
+    def test_merge_help_options(self, capsys):
+        with pytest.raises(SystemExit):
+            self.parser.parse_args(["merge", "--help"])
+        captured = capsys.readouterr()
+        assert "[--suite-name SUITE_NAME]" in captured.out
+        assert "output\n" in captured.out
+
+    @pytest.mark.parametrize("command", ["merge", "verify"])
+    def test_option_glob(
+        self,
+        command,
+    ):
+        args = self.parser.parse_args([command, "--glob", "pytest_*.xml", "-"])
+        assert args.paths_are_globs
+
+    def test_verify_argument_path(self):
+        files = ["foo", "bar"]
+        args = self.parser.parse_args(["verify", *files])
+        assert args.paths == files
+
+    def test_merge_argument_path(self):
+        files = ["foo", "bar"]
+        args = self.parser.parse_args(["merge", *files, "-"])
+        assert args.paths == files
+
+    def test_merge_option_suite_name(self):
+        args = self.parser.parse_args(["merge", "--suite-name", "foo", "_", "-"])
+        assert args.suite_name == "foo"
+
+    def test_merge_argument_output(self):
+        args = self.parser.parse_args(["merge", "foo", "bar"])
+        assert args.output == "bar"