Support multiple test sources, `test_classes`, and `non_test_srcs` in `java_test`

PiperOrigin-RevId: 985918418
Change-Id: I9600eab4e0221cb6f6ba66be80d7f4d68ccdf160
diff --git a/java/bazel/rules/bazel_java_binary.bzl b/java/bazel/rules/bazel_java_binary.bzl
index 0611e06..998ac89 100644
--- a/java/bazel/rules/bazel_java_binary.bzl
+++ b/java/bazel/rules/bazel_java_binary.bzl
@@ -79,17 +79,15 @@
         _java_runtime_version = semantics.find_java_runtime_toolchain(ctx).version
         if _java_runtime_version >= 17 and _java_runtime_version < 24:
             jvm_flags.append("-Djava.security.manager=allow")
-        test_class = ctx.attr.test_class if hasattr(ctx.attr, "test_class") else ""
-        if test_class == "":
-            test_class = helper.primary_class(ctx)
-        if test_class == None:
-            fail("cannot determine test class. You might want to rename the " +
-                 "rule or add a 'test_class' attribute.")
+        test_classes = helper.test_classes(ctx)
+        if not test_classes:
+            fail("cannot determine test class. You might want to add a " +
+                 "'test_class' or 'test_classes' attribute.")
         jvm_flags.extend([
             "-ea",
             "-D{prop_name}={prop_value}".format(
                 prop_name = semantics.TEST_SUITE_PROPERTY_NAME,
-                prop_value = helper.shell_escape(test_class),
+                prop_value = helper.shell_escape(",".join(test_classes)),
             ),
         ])
 
diff --git a/java/common/rules/impl/java_binary_impl.bzl b/java/common/rules/impl/java_binary_impl.bzl
index 13718eb..379c213 100644
--- a/java/common/rules/impl/java_binary_impl.bzl
+++ b/java/common/rules/impl/java_binary_impl.bzl
@@ -87,7 +87,14 @@
     if not ctx.attr.use_launcher and (ctx.attr.launcher and ctx.attr.launcher.label != semantics.LAUNCHER_FLAG_LABEL):
         fail("launcher specified but use_launcher is false")
 
-    if not ctx.attr.srcs and ctx.attr.deps:
+    if not is_test_rule_class:
+        if hasattr(ctx.attr, "test_classes") and ctx.attr.test_classes:
+            fail("in %s: 'test_classes' is not allowed on java_binary" % ctx.label)
+        if hasattr(ctx.attr, "non_test_srcs") and ctx.attr.non_test_srcs:
+            fail("in %s: 'non_test_srcs' is not allowed on java_binary" % ctx.label)
+
+    srcs = ctx.files.srcs + [src for src in getattr(ctx.files, "non_test_srcs", []) if src not in ctx.files.srcs]
+    if not srcs and ctx.attr.deps:
         fail("deps not allowed without srcs; move to runtime_deps?")
 
     module_flags = [dep[JavaInfo].module_flags_info for dep in runtime_deps if JavaInfo in dep]
@@ -103,7 +110,7 @@
     timezone_data = [toolchain._timezone_data] if toolchain._timezone_data else []
     target, common_info = basic_java_library(
         ctx,
-        srcs = ctx.files.srcs,
+        srcs = srcs,
         deps = deps,
         runtime_deps = runtime_deps,
         plugins = ctx.attr.plugins,
diff --git a/java/common/rules/impl/java_helper.bzl b/java/common/rules/impl/java_helper.bzl
index 638878b..2ee5427 100644
--- a/java/common/rules/impl/java_helper.bzl
+++ b/java/common/rules/impl/java_helper.bzl
@@ -88,13 +88,23 @@
             main_class = _primary_class(ctx)
     return main_class
 
+def _canonical_class_from_target_name(ctx):
+    return _full_classname(helper.get_relative(ctx.label.package, ctx.label.name))
+
 def _primary_class(ctx):
     if ctx.attr.srcs:
         main = ctx.label.name + ".java"
-        for src in ctx.files.srcs:
+        for src in _test_srcs(ctx):
             if src.basename == main:
                 return _full_classname(_strip_extension(src))
-    return _full_classname(helper.get_relative(ctx.label.package, ctx.label.name))
+    return _canonical_class_from_target_name(ctx)
+
+def _test_srcs(ctx):
+    """Returns ctx.files.srcs excluding any files listed in non_test_srcs."""
+    if not hasattr(ctx.files, "non_test_srcs") or not ctx.files.non_test_srcs:
+        return ctx.files.srcs
+    non_test_srcs = {src: None for src in ctx.files.non_test_srcs}
+    return [src for src in ctx.files.srcs if src not in non_test_srcs]
 
 def _strip_extension(file):
     return file.dirname + "/" + (
@@ -106,6 +116,28 @@
     java_segments = _loading_phase_helper.java_segments(path)
     return ".".join(java_segments) if java_segments != None else None
 
+def _test_classes(ctx):
+    has_test_class = hasattr(ctx.attr, "test_class") and bool(ctx.attr.test_class)
+    has_test_classes = hasattr(ctx.attr, "test_classes") and bool(ctx.attr.test_classes)
+    if has_test_class and has_test_classes:
+        fail("in %s: cannot specify both 'test_class' and 'test_classes'" % ctx.label)
+    if has_test_class:
+        return [ctx.attr.test_class]
+    if has_test_classes:
+        return ctx.attr.test_classes
+    if ctx.attr.srcs:
+        classes = []
+        for src in _test_srcs(ctx):
+            if src.extension == "java":
+                cls = _full_classname(_strip_extension(src))
+                if cls == None:
+                    return []
+                classes.append(cls)
+        if classes:
+            return classes
+    canonical = _canonical_class_from_target_name(ctx)
+    return [canonical] if canonical != None else []
+
 def _concat(*lists):
     result = []
     for list in lists:
@@ -256,6 +288,8 @@
     launcher_artifact_for_target = _launcher_artifact_for_target,
     check_and_get_main_class = _check_and_get_main_class,
     primary_class = _primary_class,
+    test_classes = _test_classes,
+    test_srcs = _test_srcs,
     strip_extension = _strip_extension,
     concat = _concat,
     get_shared_native_deps_path = _get_shared_native_deps_path,
diff --git a/java/common/rules/java_binary.bzl b/java/common/rules/java_binary.bzl
index 609ca97..12250ab 100644
--- a/java/common/rules/java_binary.bzl
+++ b/java/common/rules/java_binary.bzl
@@ -360,6 +360,24 @@
 </p>
         """,
     ),
+    "test_classes": attr.string_list(
+        doc = """
+The Java classes to be loaded by the test runner.
+<p>
+  By default, test classes are automatically inferred from source file names in
+  <code>srcs</code>.
+</p>
+<p>
+  Specify <code>test_classes</code> explicitly when:
+  <ul>
+    <li>The classes to run cannot be inferred from the source file names or do not
+        match the package path convention.</li>
+    <li>Only a specific subset of test classes compiled into the binary should be executed.</li>
+    <li>Test suites or base test classes are defined elsewhere.</li>
+  </ul>
+</p>
+        """,
+    ),
     "env_inherit": attr.string_list(),
     "_apple_constraints": attr.label_list(
         default = [
diff --git a/test/java/bazel/rules/java_test_tests.bzl b/test/java/bazel/rules/java_test_tests.bzl
index 5c76b0e..f01d6dc 100644
--- a/test/java/bazel/rules/java_test_tests.bzl
+++ b/test/java/bazel/rules/java_test_tests.bzl
@@ -1,11 +1,17 @@
 """Tests for the Bazel java_test rule"""
 
+load("@bazel_features//private:util.bzl", _bazel_version_ge = "ge")
 load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
 load("@rules_testing//lib:truth.bzl", "matching", "subjects")
 load("@rules_testing//lib:util.bzl", "util")
 load("//java:java_test.bzl", "java_test")
+load("//test/java/testutil:helper.bzl", "always_passes")
 
 def _test_deduced_test_class(name):
+    if not _bazel_version_ge("8.0.0"):
+        always_passes(name)
+        return
+
     util.helper_target(
         java_test,
         name = name + "/foo",
@@ -16,6 +22,7 @@
         name = name,
         impl = _test_deduced_test_class_impl,
         target = name + "/foo",
+        attr_values = {"tags": ["min_bazel_8"]},
     )
 
 def _test_deduced_test_class_impl(env, target):
@@ -32,7 +39,7 @@
         # Windows
         assert_jvm_flags = assert_action.argv()
     assert_jvm_flags.contains_predicate(
-        matching.str_matches("-Dbazel.test_suite=bazel.rules.test_deduced_test_class.foo"),
+        matching.str_matches("-Dbazel.test_suite=bazel.rules.test_deduced_test_class.Foo"),
     )
 
 # regression test for https://github.com/bazelbuild/bazel/issues/20378
@@ -49,11 +56,73 @@
         matching.str_matches("cannot determine test class."),
     )
 
+def _test_test_classes(name):
+    if not _bazel_version_ge("8.0.0"):
+        always_passes(name)
+        return
+
+    util.helper_target(
+        java_test,
+        name = name + "/foo",
+        test_classes = ["com.example.FooTest", "com.example.BarTest"],
+    )
+
+    analysis_test(
+        name = name,
+        impl = _test_test_classes_impl,
+        target = name + "/foo",
+        attr_values = {"tags": ["min_bazel_8"]},
+    )
+
+def _test_test_classes_impl(env, target):
+    executable = target[DefaultInfo].files_to_run.executable.short_path
+    assert_action = env.expect.that_target(target).action_generating(executable)
+
+    if assert_action.actual.substitutions:
+        # TemplateExpansion action on linux/mac
+        assert_jvm_flags = assert_action.substitutions().get(
+            "%jvm_flags%",
+            factory = lambda v, meta: subjects.collection([v], meta),
+        )
+    else:
+        # Windows
+        assert_jvm_flags = assert_action.argv()
+    assert_jvm_flags.contains_predicate(
+        matching.str_matches("-Dbazel.test_suite=com.example.FooTest,com.example.BarTest"),
+    )
+
+def _test_both_test_class_and_test_classes_fails(name):
+    if not _bazel_version_ge("8.0.0"):
+        always_passes(name)
+        return
+
+    util.helper_target(
+        java_test,
+        name = name + "/foo",
+        test_class = "FooTest",
+        test_classes = ["FooTest"],
+    )
+
+    analysis_test(
+        name = name,
+        impl = _test_both_test_class_and_test_classes_fails_impl,
+        target = name + "/foo",
+        expect_failure = True,
+        attr_values = {"tags": ["min_bazel_8"]},
+    )
+
+def _test_both_test_class_and_test_classes_fails_impl(env, target):
+    env.expect.that_target(target).failures().contains_predicate(
+        matching.str_matches("cannot specify both 'test_class' and 'test_classes'"),
+    )
+
 def java_test_tests(name):
     test_suite(
         name = name,
         tests = [
             _test_deduced_test_class,
             _test_invalid_test_class_at_repo_root,
+            _test_test_classes,
+            _test_both_test_class_and_test_classes_fails,
         ],
     )