Skip to content

Commit 8a04f12

Browse files
authored
Add java-test-runner module to support running tests with pure Java (#4197)
* Add `java-test-runner` module to support running tests with pure Java * Add more logs for debugging `java-test-runner` rather than silencing exceptions * Add more logs for debugging `test-runner`, mirroring `java-test-runner` approach * Adjust log verbosity in `test-runner`/`java-test-runner` * Only hardcode pure Java frameworks in `java-test-runner`
1 parent 5d8f051 commit 8a04f12

21 files changed

Lines changed: 1197 additions & 118 deletions

File tree

build.mill

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ object runner extends Cross[Runner](Scala.runnerScalaVersions)
112112
with CrossScalaDefaultToRunner
113113
object `test-runner` extends Cross[TestRunner](Scala.runnerScalaVersions)
114114
with CrossScalaDefaultToRunner
115+
object `java-test-runner` extends JavaTestRunner
116+
with LocatedInModules
115117
object `tasty-lib` extends Cross[TastyLib](Scala.scala3MainVersions)
116118
with CrossScalaDefaultToInternal
117119

@@ -452,12 +454,18 @@ trait Core extends ScalaCliCrossSbtModule
452454
val runnerMainClass = build.runner(crossScalaVersion)
453455
.mainClass()
454456
.getOrElse(sys.error("No main class defined for runner"))
457+
val javaTestRunnerMainClass = `java-test-runner`
458+
.mainClass()
459+
.getOrElse(sys.error("No main class defined for java-test-runner"))
455460
val detailedVersionValue =
456461
if (`local-repo`.developingOnStubModules) s"""Some("${vcsState()}")"""
457462
else "None"
458463
val testRunnerOrganization = `test-runner`(crossScalaVersion)
459464
.pomSettings()
460465
.organization
466+
val javaTestRunnerOrganization = `java-test-runner`
467+
.pomSettings()
468+
.organization
461469
val code =
462470
s"""package scala.build.internal
463471
|
@@ -479,6 +487,11 @@ trait Core extends ScalaCliCrossSbtModule
479487
| def testRunnerVersion = "${`test-runner`(crossScalaVersion).publishVersion()}"
480488
| def testRunnerMainClass = "$testRunnerMainClass"
481489
|
490+
| def javaTestRunnerOrganization = "$javaTestRunnerOrganization"
491+
| def javaTestRunnerModuleName = "${`java-test-runner`.artifactName()}"
492+
| def javaTestRunnerVersion = "${`java-test-runner`.publishVersion()}"
493+
| def javaTestRunnerMainClass = "$javaTestRunnerMainClass"
494+
|
482495
| def runnerOrganization = "${build.runner(crossScalaVersion).pomSettings().organization}"
483496
| def runnerModuleName = "${build.runner(crossScalaVersion).artifactName()}"
484497
| def runnerVersion = "${build.runner(crossScalaVersion).publishVersion()}"
@@ -1323,6 +1336,16 @@ trait TestRunner extends CrossSbtModule
13231336
override def mainClass: T[Option[String]] = Some("scala.build.testrunner.DynamicTestRunner")
13241337
}
13251338

1339+
trait JavaTestRunner extends JavaModule
1340+
with ScalaCliPublishModule
1341+
with LocatedInModules {
1342+
override def mvnDeps: T[Seq[Dep]] = super.mvnDeps() ++ Seq(
1343+
Deps.asm,
1344+
Deps.testInterface
1345+
)
1346+
override def mainClass: T[Option[String]] = Some("scala.build.testrunner.JavaDynamicTestRunner")
1347+
}
1348+
13261349
trait TastyLib extends ScalaCliCrossSbtModule
13271350
with ScalaCliPublishModule
13281351
with ScalaCliScalafixModule
@@ -1357,7 +1380,7 @@ object `local-repo` extends LocalRepo {
13571380
def developingOnStubModules = false
13581381

13591382
override def stubsModules: Seq[PublishLocalNoFluff] =
1360-
Seq(runner(Scala.runnerScala3), `test-runner`(Scala.runnerScala3))
1383+
Seq(runner(Scala.runnerScala3), `test-runner`(Scala.runnerScala3), `java-test-runner`)
13611384

13621385
override def version: T[String] = runner(Scala.runnerScala3).publishVersion()
13631386
}

modules/build/src/main/scala/scala/build/Build.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,8 +1105,7 @@ object Build {
11051105
either {
11061106

11071107
val options0 =
1108-
// FIXME: don't add Scala to pure Java test builds (need to add pure Java test runner)
1109-
if sources.hasJava && !sources.hasScala && scope != Scope.Test
1108+
if sources.hasJava && !sources.hasScala
11101109
then
11111110
options.copy(
11121111
scalaOptions = options.scalaOptions.copy(

modules/build/src/main/scala/scala/build/internal/Runner.scala

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@ import scala.build.Logger
1515
import scala.build.errors.*
1616
import scala.build.internals.EnvVar
1717
import scala.build.testrunner.FrameworkUtils.*
18-
import scala.build.testrunner.{AsmTestRunner, TestRunner}
18+
import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger, TestRunner}
1919
import scala.scalanative.testinterface.adapter.TestAdapter as ScalaNativeTestAdapter
2020
import scala.util.{Failure, Properties, Success}
2121

2222
object Runner {
2323

24+
private def toTestRunnerLogger(logger: Logger): TestRunnerLogger =
25+
TestRunnerLogger(logger.verbosity)
26+
2427
def maybeExec(
2528
commandName: String,
2629
command: Seq[String],
@@ -346,15 +349,18 @@ object Runner {
346349
frameworks: Seq[Framework],
347350
requireTests: Boolean,
348351
args: Seq[String],
349-
parentInspector: AsmTestRunner.ParentInspector
352+
parentInspector: AsmTestRunner.ParentInspector,
353+
logger: Logger
350354
): Either[NoTestsRun, Boolean] = frameworks
351355
.flatMap { framework =>
356+
val trLogger = toTestRunnerLogger(logger)
352357
val taskDefs =
353358
AsmTestRunner.taskDefs(
354359
classPath,
355360
keepJars = false,
356361
framework.fingerprints().toIndexedSeq,
357-
parentInspector
362+
parentInspector,
363+
trLogger
358364
).toArray
359365

360366
val runner = framework.runner(args.toArray, Array(), null)
@@ -380,16 +386,22 @@ object Runner {
380386
parentInspector: AsmTestRunner.ParentInspector,
381387
logger: Logger
382388
): Either[NoTestFrameworkFoundError, Seq[String]] = {
389+
val trLogger = toTestRunnerLogger(logger)
383390
logger.debug("Looking for test framework services on the classpath...")
384391
val foundFrameworkServices =
385-
AsmTestRunner.findFrameworkServices(classPath)
392+
AsmTestRunner.findFrameworkServices(classPath, trLogger)
386393
.map(_.replace('/', '.').replace('\\', '.'))
387394
logger.debug(s"Found ${foundFrameworkServices.length} test framework services.")
388395
if foundFrameworkServices.nonEmpty then
389396
logger.debug(s" - ${foundFrameworkServices.mkString("\n - ")}")
390397
logger.debug("Looking for more test frameworks on the classpath...")
391398
val foundFrameworks =
392-
AsmTestRunner.findFrameworks(classPath, TestRunner.commonTestFrameworks, parentInspector)
399+
AsmTestRunner.findFrameworks(
400+
classPath,
401+
TestRunner.commonTestFrameworks,
402+
parentInspector,
403+
trLogger
404+
)
393405
.map(_.replace('/', '.').replace('\\', '.'))
394406
logger.debug(s"Found ${foundFrameworks.length} additional test frameworks")
395407
if foundFrameworks.nonEmpty then
@@ -444,7 +456,7 @@ object Runner {
444456

445457
logger.debug(s"JS tests class path: $classPath")
446458

447-
val parentInspector = new AsmTestRunner.ParentInspector(classPath)
459+
val parentInspector = new AsmTestRunner.ParentInspector(classPath, toTestRunnerLogger(logger))
448460
val foundFrameworkNames: List[String] = predefinedTestFrameworks match {
449461
case f if f.nonEmpty => f.toList
450462
case Nil => value(frameworkNames(classPath, parentInspector, logger)).toList
@@ -474,7 +486,7 @@ object Runner {
474486
)
475487

476488
if finalTestFrameworks.isEmpty then Left(new NoFrameworkFoundByBridgeError)
477-
else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector)
489+
else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector, logger)
478490
}
479491
finally if adapter != null then adapter.close()
480492

@@ -492,7 +504,7 @@ object Runner {
492504
logger.debug("Preparing to run tests with Scala Native...")
493505
logger.debug(s"Native tests class path: $classPath")
494506

495-
val parentInspector = new AsmTestRunner.ParentInspector(classPath)
507+
val parentInspector = new AsmTestRunner.ParentInspector(classPath, toTestRunnerLogger(logger))
496508
val foundFrameworkNames: List[String] = predefinedTestFrameworks match {
497509
case f if f.nonEmpty => f.toList
498510
case Nil => value(frameworkNames(classPath, parentInspector, logger)).toList
@@ -540,7 +552,7 @@ object Runner {
540552
)
541553

542554
if finalTestFrameworks.isEmpty then Left(new NoFrameworkFoundByNativeBridgeError)
543-
else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector)
555+
else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector, logger)
544556
}
545557
finally if adapter != null then adapter.close()
546558

modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package scala.build.tests
33
import java.nio.file.Files
44

55
import scala.build.errors.NoFrameworkFoundByNativeBridgeError
6-
import scala.build.testrunner.AsmTestRunner
6+
import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger}
77

88
class FrameworkDiscoveryTests extends TestUtil.ScalaCliBuildSuite {
99

@@ -25,7 +25,7 @@ class FrameworkDiscoveryTests extends TestUtil.ScalaCliBuildSuite {
2525
|""".stripMargin
2626
Files.writeString(serviceFile, content)
2727

28-
val found = AsmTestRunner.findFrameworkServices(Seq(dir))
28+
val found = AsmTestRunner.findFrameworkServices(Seq(dir), TestRunnerLogger(0))
2929
assertEquals(
3030
found.sorted,
3131
Seq("munit.Framework", "munit.native.Framework"),
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package scala.build.tests
2+
3+
import com.eed3si9n.expecty.Expecty.assert as expect
4+
5+
import scala.build.options.*
6+
7+
class JavaTestRunnerTests extends TestUtil.ScalaCliBuildSuite {
8+
9+
private def makeOptions(
10+
scalaVersionOpt: Option[MaybeScalaVersion],
11+
addTestRunner: Boolean
12+
): BuildOptions =
13+
BuildOptions(
14+
scalaOptions = ScalaOptions(
15+
scalaVersion = scalaVersionOpt
16+
),
17+
internalDependencies = InternalDependenciesOptions(
18+
addTestRunnerDependencyOpt = Some(addTestRunner)
19+
)
20+
)
21+
22+
test("pure Java build has no scalaParams") {
23+
val opts = makeOptions(Some(MaybeScalaVersion.none), addTestRunner = false)
24+
val params = opts.scalaParams.toOption.flatten
25+
expect(params.isEmpty, "Pure Java build should have no scalaParams")
26+
}
27+
28+
test("Scala build has scalaParams") {
29+
val opts = makeOptions(None, addTestRunner = false)
30+
val params = opts.scalaParams.toOption.flatten
31+
expect(params.isDefined, "Scala build should have scalaParams")
32+
}
33+
34+
test("pure Java test build gets addJvmJavaTestRunner=true in Artifacts params") {
35+
val opts = makeOptions(Some(MaybeScalaVersion.none), addTestRunner = true)
36+
val isJava = opts.scalaParams.toOption.flatten.isEmpty
37+
expect(isJava, "Expected pure Java build to have no scalaParams")
38+
}
39+
40+
test("Scala test build gets addJvmTestRunner=true in Artifacts params") {
41+
val opts = makeOptions(None, addTestRunner = true)
42+
val isJava = opts.scalaParams.toOption.flatten.isEmpty
43+
expect(!isJava, "Expected Scala build to have scalaParams")
44+
}
45+
46+
test("mixed Scala+Java build still gets Scala test runner") {
47+
val opts = makeOptions(None, addTestRunner = true)
48+
val isJava = opts.scalaParams.toOption.flatten.isEmpty
49+
expect(!isJava, "Mixed Scala+Java build should still use Scala test runner")
50+
}
51+
}

modules/cli/src/main/scala/scala/cli/commands/test/Test.scala

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import scala.build.errors.{BuildException, CompositeBuildException}
1212
import scala.build.internal.{Constants, Runner}
1313
import scala.build.internals.ConsoleUtils.ScalaCliConsole
1414
import scala.build.options.{BuildOptions, JavaOpt, Platform, Scope}
15-
import scala.build.testrunner.AsmTestRunner
15+
import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger}
1616
import scala.cli.CurrentParams
1717
import scala.cli.commands.run.Run
1818
import scala.cli.commands.setupide.SetupIde
@@ -256,11 +256,16 @@ object Test extends ScalaCommand[TestOptions] {
256256
testOnly.map(to => s"--test-only=$to").toSeq ++
257257
Seq("--") ++ args
258258

259+
val testRunnerMainClass =
260+
if build.artifacts.hasJavaTestRunner
261+
then Constants.javaTestRunnerMainClass
262+
else Constants.testRunnerMainClass
263+
259264
Runner.runJvm(
260265
build.options.javaHome().value.javaCommand,
261266
build.options.javaOptions.javaOpts.toSeq.map(_.value.value),
262267
classPath,
263-
Constants.testRunnerMainClass,
268+
testRunnerMainClass,
264269
extraArgs,
265270
logger,
266271
allowExecve = allowExecve
@@ -274,7 +279,8 @@ object Test extends ScalaCommand[TestOptions] {
274279
// https://github.com/VirtusLab/scala-cli/issues/426
275280
if classPath0.exists(_.contains("zio-test")) && !classPath0.exists(_.contains("zio-test-sbt"))
276281
then {
277-
val parentInspector = new AsmTestRunner.ParentInspector(classPath)
282+
val parentInspector =
283+
new AsmTestRunner.ParentInspector(classPath, TestRunnerLogger(logger.verbosity))
278284
Runner.frameworkNames(classPath, parentInspector, logger) match {
279285
case Right(f) => f.headOption
280286
case Left(_) =>

modules/cli/src/main/scala/scala/cli/exportCmd/MillProjectDescriptor.scala

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import scala.build.errors.BuildException
99
import scala.build.internal.Constants
1010
import scala.build.internal.Runner.frameworkNames
1111
import scala.build.options.{BuildOptions, Platform, ScalaJsOptions, ScalaNativeOptions, Scope}
12-
import scala.build.testrunner.AsmTestRunner
12+
import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger}
1313
import scala.build.{Logger, Sources}
1414
import scala.cli.ScalaCli
1515

@@ -137,8 +137,9 @@ final case class MillProjectDescriptor(
137137
logger.debug(exception.message)
138138
Seq.empty
139139
}
140-
val parentInspector = new AsmTestRunner.ParentInspector(testClassPath)
141-
val frameworkName0 = options.testOptions.frameworks.headOption.orElse {
140+
val parentInspector =
141+
new AsmTestRunner.ParentInspector(testClassPath, TestRunnerLogger(logger.verbosity))
142+
val frameworkName0 = options.testOptions.frameworks.headOption.orElse {
142143
frameworkNames(testClassPath, parentInspector, logger).toOption
143144
.flatMap(_.headOption) // TODO: handle multiple frameworks here
144145
}

modules/cli/src/main/scala/scala/cli/exportCmd/SbtProjectDescriptor.scala

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import scala.build.options.{
1717
Scope,
1818
ShadowingSeq
1919
}
20-
import scala.build.testrunner.AsmTestRunner
20+
import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger}
2121
import scala.build.{Logger, Positioned, Sources}
2222
import scala.cli.ScalaCli
2323

@@ -258,8 +258,9 @@ final case class SbtProjectDescriptor(
258258
Seq.empty
259259
}
260260

261-
val parentInspector = new AsmTestRunner.ParentInspector(testClassPath)
262-
val frameworkName0 = options.testOptions.frameworks.headOption.orElse {
261+
val parentInspector =
262+
new AsmTestRunner.ParentInspector(testClassPath, TestRunnerLogger(logger.verbosity))
263+
val frameworkName0 = options.testOptions.frameworks.headOption.orElse {
263264
frameworkNames(testClassPath, parentInspector, logger).toOption
264265
.flatMap(_.headOption) // TODO: handle multiple frameworks here
265266
}

modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,30 @@ class RunTestsDefault extends RunTestDefinitions
236236
expect(res.err.trim().contains(expectedWarning))
237237
}
238238
}
239+
240+
for {
241+
buildServerOptions <- Seq(Nil, Seq("--server=false"))
242+
buildServerDesc =
243+
if buildServerOptions.isEmpty then "with build server" else "without build server"
244+
}
245+
test(s"pure Java run has no Scala on classpath $buildServerDesc") {
246+
TestInputs(
247+
os.rel / "Main.java" ->
248+
"""public class Main {
249+
| public static void main(String[] args) {
250+
| try {
251+
| Class.forName("scala.Predef");
252+
| throw new RuntimeException("Scala should not be on the classpath");
253+
| } catch (ClassNotFoundException e) {
254+
| System.out.println("No Scala on classpath!");
255+
| }
256+
| }
257+
|}
258+
|""".stripMargin
259+
).fromRoot { root =>
260+
val res =
261+
os.proc(TestUtil.cli, "run", buildServerOptions, extraOptions, ".").call(cwd = root)
262+
expect(res.out.text().contains("No Scala on classpath!"))
263+
}
264+
}
239265
}

modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,38 @@ class TestTestsDefault extends TestTestDefinitions with TestDefault {
9898
expect(err.countOccurrences(expectedWarning) == 1)
9999
}
100100
}
101+
102+
for {
103+
buildServerOptions <- Seq(Nil, Seq("--server=false"))
104+
buildServerDesc =
105+
if buildServerOptions.isEmpty then "with build server" else "without build server"
106+
}
107+
test(s"pure Java test with JUnit has no Scala on classpath $buildServerDesc") {
108+
TestInputs(
109+
os.rel / "test" / "MyTests.java" ->
110+
"""//> using test.dep junit:junit:4.13.2
111+
|//> using test.dep com.novocode:junit-interface:0.11
112+
|import org.junit.Test;
113+
|import static org.junit.Assert.assertEquals;
114+
|
115+
|public class MyTests {
116+
| @Test
117+
| public void foo() {
118+
| try {
119+
| Class.forName("scala.Predef");
120+
| throw new AssertionError("Scala should not be on the classpath");
121+
| } catch (ClassNotFoundException e) {
122+
| // expected
123+
| }
124+
| assertEquals(4, 2 + 2);
125+
| System.out.println("No Scala on classpath!");
126+
| }
127+
|}
128+
|""".stripMargin
129+
).fromRoot { root =>
130+
val res =
131+
os.proc(TestUtil.cli, "test", extraOptions, buildServerOptions, ".").call(cwd = root)
132+
expect(res.out.text().contains("No Scala on classpath!"))
133+
}
134+
}
101135
}

0 commit comments

Comments
 (0)