diff --git a/smart_tests/test_runners/maven.py b/smart_tests/test_runners/maven.py
index 28e49095f..1474484d2 100644
--- a/smart_tests/test_runners/maven.py
+++ b/smart_tests/test_runners/maven.py
@@ -1,6 +1,7 @@
import glob
import os
import re
+import xml.etree.ElementTree as ET
from typing import Annotated, Dict, List
import click
@@ -50,6 +51,29 @@ def is_file(f: str) -> bool:
return False
+def parse_surefire_reports() -> List[Dict[str, str]]:
+ """Collect the test classes Surefire discovered from its TEST-*.xml reports.
+
+ Intended to be run against reports produced by a JUnit 5 dry run
+ (`mvn test -Djunit.platform.execution.dryRun.enabled=true`): Surefire applies
+ all of its own filtering (excludedGroups, includedGroups, excludes, profiles,
+ ...) before the dry run, so only the tests Maven would actually execute get a
+ report. Each report's root `name` attribute is the fully-qualified class name.
+ """
+ test_paths = []
+ report_pattern = os.path.join('**', 'target', 'surefire-reports', 'TEST-*.xml')
+ report_files = glob.glob(report_pattern, recursive=True)
+
+ for report_file in report_files:
+ try:
+ if classname := ET.parse(report_file).getroot().get('name'):
+ test_paths.append({"type": "class", "name": classname})
+ except ET.ParseError as e:
+ click.secho(f"Warning: Could not parse {report_file}: {e}", fg='yellow', err=True)
+
+ return test_paths
+
+
@smart_tests.subset
def subset(
client: Subset,
@@ -68,6 +92,12 @@ def subset(
help="Scan testCompile/default-testCompile/createdFiles.lst for *.lst files generated by `mvn compile` and "
"use them as test inputs."
)] = False,
+ is_scan_dryrun_results: Annotated[bool, typer.Option(
+ "--scan-dryrun-results",
+ help="Scan surefire reports generated by `mvn test -Djunit.platform.execution.dryRun.enabled=true`. "
+ "Only the tests Maven would actually run (after applying pom.xml excludedGroups/excludes/etc.) are "
+ "sent to the subset. Run the dry run before this command. JUnit 5 only."
+ )] = False,
exclude_rules: Annotated[List[str], typer.Option(
"--exclude",
multiple=True,
@@ -108,6 +138,18 @@ def file2test(f: str) -> List | None:
if source_roots is None:
source_roots = []
+ if is_scan_dryrun_results:
+ tests = parse_surefire_reports()
+ if not tests:
+ raise BadCmdLineException(
+ "No surefire reports found under **/target/surefire-reports/. "
+ "Run `mvn test -Djunit.platform.execution.dryRun.enabled=true` before this command.")
+ for test in tests:
+ client.test_paths.append([test])
+ client.same_bin_formatter = lambda s: [{"type": "class", "name": s}]
+ client.run()
+ return
+
files_to_read = list(test_compile_created_file)
if is_scan_test_compile_lst:
if len(test_compile_created_file) > 0:
diff --git a/tests/data/maven/dryrun-test/target/surefire-reports/TEST-com.example.demo.CalculatorTest.xml b/tests/data/maven/dryrun-test/target/surefire-reports/TEST-com.example.demo.CalculatorTest.xml
new file mode 100644
index 000000000..cfabbfa73
--- /dev/null
+++ b/tests/data/maven/dryrun-test/target/surefire-reports/TEST-com.example.demo.CalculatorTest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/tests/data/maven/dryrun-test/target/surefire-reports/TEST-com.example.demo.UserTest.xml b/tests/data/maven/dryrun-test/target/surefire-reports/TEST-com.example.demo.UserTest.xml
new file mode 100644
index 000000000..5d3503962
--- /dev/null
+++ b/tests/data/maven/dryrun-test/target/surefire-reports/TEST-com.example.demo.UserTest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/tests/data/maven/subset_scan_dryrun_results_result.json b/tests/data/maven/subset_scan_dryrun_results_result.json
new file mode 100644
index 000000000..09552db2f
--- /dev/null
+++ b/tests/data/maven/subset_scan_dryrun_results_result.json
@@ -0,0 +1,12 @@
+{
+ "testPaths": [
+ [{"type": "class", "name": "com.example.demo.CalculatorTest"}],
+ [{"type": "class", "name": "com.example.demo.UserTest"}]
+ ],
+ "testRunner": "maven",
+ "goal": {"type": "subset-by-percentage", "percentage": 0.1},
+ "ignoreNewTests": false,
+ "session": {"id": "16"},
+ "getTestsFromGuess": false,
+ "getTestsFromPreviousSessions": false
+}
diff --git a/tests/test_runners/test_maven.py b/tests/test_runners/test_maven.py
index 5b64fea40..e5a0594db 100644
--- a/tests/test_runners/test_maven.py
+++ b/tests/test_runners/test_maven.py
@@ -167,6 +167,36 @@ def test_subset_with_exclude(self):
self.assert_success(result)
self.assert_subset_payload('subset_with_exclude_rules_result.json')
+ @responses.activate
+ @mock.patch.dict(os.environ, {"SMART_TESTS_TOKEN": CliTestCase.smart_tests_token})
+ def test_scan_dryrun_results(self):
+ # Reports live under /target/surefire-reports/. The flag globs
+ # **/target/surefire-reports/TEST-*.xml relative to the cwd, so run from there.
+ test_data_dir = str(self.test_files_dir.joinpath('dryrun-test').resolve())
+ original_dir = os.getcwd()
+ try:
+ os.chdir(test_data_dir)
+ result = self.cli('subset', '--target', '10%', '--session',
+ self.session, 'maven', '--scan-dryrun-results')
+ self.assert_success(result)
+ self.assert_subset_payload('subset_scan_dryrun_results_result.json')
+ finally:
+ os.chdir(original_dir)
+
+ @responses.activate
+ @mock.patch.dict(os.environ, {"SMART_TESTS_TOKEN": CliTestCase.smart_tests_token})
+ def test_scan_dryrun_results_no_reports(self):
+ original_dir = os.getcwd()
+ with tempfile.TemporaryDirectory() as temp_dir:
+ try:
+ os.chdir(temp_dir)
+ result = self.cli('subset', '--target', '10%', '--session',
+ self.session, 'maven', '--scan-dryrun-results')
+ self.assertNotEqual(result.exit_code, 0)
+ self.assertIn("No surefire reports found", result.output)
+ finally:
+ os.chdir(original_dir)
+
def test_glob(self):
for x in [
'foo/BarTest.java',