Skip to content

Commit 04e5d7d

Browse files
authored
Add example for using Java's source launcher
1 parent 12fae04 commit 04e5d7d

File tree

8 files changed

+200
-2
lines changed

8 files changed

+200
-2
lines changed

junit-source-launcher/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
out/
2+
3+
*.jar

junit-source-launcher/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# junit-source-launcher
2+
3+
Starting with Java 25 it is possible to write minimal source code test programs using the `org.junit.start` module.
4+
For example, take a look at the [HelloTests.java](src/HelloTests.java) file reading:
5+
6+
```java
7+
import module org.junit.start;
8+
9+
void main() {
10+
JUnit.run();
11+
}
12+
13+
@Test
14+
void stringLength() {
15+
Assertions.assertEquals(11, "Hello JUnit".length());
16+
}
17+
```
18+
19+
Download `org.junit.start` module and its transitively required modules into a local `lib/` directory by running in a shell:
20+
21+
```shell
22+
java lib/DownloadRequiredModules.java
23+
```
24+
25+
With all required modular JAR files available in a local `lib/` directory, the following Java command will discover and execute tests using the JUnit Platform.
26+
27+
```shell
28+
java --module-path lib --add-modules org.junit.start src/HelloTests.java
29+
```
30+
31+
It will also print the result tree to the console.
32+
33+
```text
34+
35+
└─ JUnit Jupiter ✔
36+
└─ HelloTests ✔
37+
└─ stringLength() ✔
38+
```
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
final Path lib = Path.of("lib"); // local directory to be used in module path
12+
final Set<String> roots = Set.of("org.junit.start"); // single root module to lookup
13+
final String version = "6.1.0-M1"; // of JUnit Framework
14+
final String repository = "https://repo.maven.apache.org/maven2"; // of JUnit Framework
15+
final String lookup =
16+
//language=Properties
17+
"""
18+
org.apiguardian.api=https://repo.maven.apache.org/maven2/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar
19+
org.jspecify=https://repo.maven.apache.org/maven2/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar
20+
org.junit.jupiter.api={{repository}}/org/junit/jupiter/junit-jupiter-api/{{version}}/junit-jupiter-api-{{version}}.jar
21+
org.junit.jupiter.engine={{repository}}/org/junit/jupiter/junit-jupiter-engine/{{version}}/junit-jupiter-engine-{{version}}.jar
22+
org.junit.jupiter.params={{repository}}/org/junit/jupiter/junit-jupiter-params/{{version}}/junit-jupiter-params-{{version}}.jar
23+
org.junit.jupiter={{repository}}/org/junit/jupiter/junit-jupiter/{{version}}/junit-jupiter-{{version}}.jar
24+
org.junit.platform.commons={{repository}}/org/junit/platform/junit-platform-commons/{{version}}/junit-platform-commons-{{version}}.jar
25+
org.junit.platform.console={{repository}}/org/junit/platform/junit-platform-console/{{version}}/junit-platform-console-{{version}}.jar
26+
org.junit.platform.engine={{repository}}/org/junit/platform/junit-platform-engine/{{version}}/junit-platform-engine-{{version}}.jar
27+
org.junit.platform.launcher={{repository}}/org/junit/platform/junit-platform-launcher/{{version}}/junit-platform-launcher-{{version}}.jar
28+
org.junit.platform.reporting={{repository}}/org/junit/platform/junit-platform-reporting/{{version}}/junit-platform-reporting-{{version}}.jar
29+
org.junit.platform.suite.api={{repository}}/org/junit/platform/junit-platform-suite-api/{{version}}/junit-platform-suite-api-{{version}}.jar
30+
org.junit.platform.suite.engine={{repository}}/org/junit/platform/junit-platform-suite-engine/{{version}}/junit-platform-suite-engine-{{version}}.jar
31+
org.junit.platform.suite={{repository}}/org/junit/platform/junit-platform-suite/{{version}}/junit-platform-suite-{{version}}.jar
32+
org.junit.start={{repository}}/org/junit/junit-start/{{version}}/junit-start-{{version}}.jar
33+
org.opentest4j.reporting.tooling.spi=https://repo.maven.apache.org/maven2/org/opentest4j/reporting/open-test-reporting-tooling-spi/0.2.5/open-test-reporting-tooling-spi-0.2.5.jar
34+
org.opentest4j=https://repo.maven.apache.org/maven2/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar
35+
"""
36+
.replace("{{repository}}", repository)
37+
.replace("{{version}}", version);
38+
39+
void main() throws Exception {
40+
// Ensure being launched inside expected working directory
41+
var program = Path.of("src", "HelloTests.java");
42+
if (!Files.exists(program)) {
43+
throw new AssertionError("Expected %s in current working directory".formatted(program));
44+
}
45+
46+
// Read mapping file to locate remote modules
47+
var properties = new Properties();
48+
properties.load(new StringReader(lookup));
49+
50+
// Create and initialize lib directory with root module(s)
51+
Files.createDirectories(lib);
52+
downloadModules(roots, properties);
53+
54+
// Compute missing modules and download them transitively
55+
var missing = computeMissingModuleNames();
56+
while (!missing.isEmpty()) {
57+
downloadModules(missing, properties);
58+
missing = computeMissingModuleNames();
59+
}
60+
61+
IO.println("%nList modules of %s directory".formatted(lib));
62+
listModules();
63+
}
64+
65+
void downloadModules(Set<String> names, Properties properties) {
66+
IO.println("Downloading %d module%s".formatted(names.size(), names.size() == 1 ? "" : "s"));
67+
names.stream().parallel().forEach(name -> {
68+
var target = lib.resolve(name + ".jar");
69+
if (Files.exists(target)) return; // Don't overwrite existing JAR file
70+
var source = URI.create(properties.getProperty(name));
71+
try (var stream = source.toURL().openStream()) {
72+
IO.println(name + " <- " + source + "...");
73+
Files.copy(stream, target);
74+
} catch (IOException cause) {
75+
throw new UncheckedIOException(cause);
76+
}
77+
});
78+
// Ensure that every name can be found to avoid eternal loops
79+
var finder = ModuleFinder.of(lib);
80+
var remainder = new TreeSet<>(names);
81+
remainder.removeIf(name -> finder.find(name).isPresent());
82+
if (remainder.isEmpty()) return;
83+
throw new AssertionError("Modules not downloaded: " + remainder);
84+
}
85+
86+
Set<String> computeMissingModuleNames() {
87+
var system = ModuleFinder.ofSystem();
88+
var finder = ModuleFinder.of(lib);
89+
var names =
90+
finder.findAll().stream()
91+
.parallel()
92+
.map(ModuleReference::descriptor)
93+
.map(ModuleDescriptor::requires)
94+
.flatMap(Collection::stream)
95+
.filter(this::mustBePresentAtCompileTime)
96+
.map(ModuleDescriptor.Requires::name)
97+
.filter(name -> finder.find(name).isEmpty())
98+
.filter(name -> system.find(name).isEmpty())
99+
.toList();
100+
return new TreeSet<>(names);
101+
}
102+
103+
boolean mustBePresentAtCompileTime(ModuleDescriptor.Requires requires) {
104+
var isStatic = requires.modifiers().contains(ModuleDescriptor.Requires.Modifier.STATIC);
105+
var isTransitive = requires.modifiers().contains(ModuleDescriptor.Requires.Modifier.TRANSITIVE);
106+
return !isStatic || isTransitive;
107+
}
108+
109+
void listModules() {
110+
var finder = ModuleFinder.of(lib);
111+
var modules = finder.findAll();
112+
modules.stream()
113+
.map(ModuleReference::descriptor)
114+
.map(ModuleDescriptor::toNameAndVersion)
115+
.sorted()
116+
.forEach(IO::println);
117+
IO.println(" %d modules".formatted(modules.size()));
118+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
import module org.junit.start;
12+
13+
void main() {
14+
JUnit.run();
15+
}
16+
17+
@Test
18+
void stringLength() {
19+
Assertions.assertEquals(11, "Hello JUnit".length());
20+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
##
2+
## Enable JUnit Platform Reporting
3+
## -> https://docs.junit.org/current/user-guide/#junit-platform-reporting
4+
##
5+
# junit.platform.reporting.output.dir=out/junit-{uniqueNumber}
6+
# junit.platform.reporting.open.xml.enabled=true

src/Builder.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ int build(Target target, Set<String> excludedProjects) {
7777
// modular
7878
runProject(excludedProjects, "junit-modular-world", "java", modularAction);
7979

80+
// source launcher
81+
runProject(excludedProjects, "junit-source-launcher", "java", "lib/DownloadRequiredModules.java");
82+
runProject(excludedProjects, "junit-source-launcher",
83+
"java",
84+
"--module-path", "lib",
85+
"--add-modules", "org.junit.start",
86+
"src/HelloTests.java");
8087
System.out.printf("%n%n%n|%n| Done. Build exits with status = %d.%n|%n", status);
8188
return status;
8289
}
@@ -141,7 +148,7 @@ void run(String directory, String executable, String... args) {
141148
}
142149

143150
boolean isWindows() {
144-
return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win");
151+
return System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("win");
145152
}
146153

147154
void checkLicense(String blueprint, String... extensions) {

src/StagingRepoInjector.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ private void inject() throws Exception {
8282

8383
appendAfter("junit-multiple-engines/build.gradle.kts", "mavenCentral()",
8484
gradleKotlinDslSnippet);
85+
86+
replace("junit-source-launcher/lib/DownloadRequiredModules.java", "String repository = \"https://repo.maven.apache.org/maven\"",
87+
"String repository = \"%s\"".formatted(stagingRepoUrl));
8588
}
8689

8790
void appendAfter(String path, String token, String addedContent) throws IOException {

src/Updater.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import java.util.regex.Pattern;
1818

1919
/**
20-
* Updates the versions of JUnit Platform artifacts in all example projects.
20+
* Updates the versions of JUnit Framework artifacts in all example projects.
2121
*/
2222
@SuppressWarnings({"WeakerAccess", "SameParameterValue"})
2323
class Updater {
@@ -72,6 +72,9 @@ void update() throws IOException {
7272
update(Path.of("junit-multiple-engines/build.gradle.kts"), List.of(
7373
Pattern.compile("junitBomVersion = \"" + VERSION_REGEX + '"')
7474
));
75+
update(Path.of("junit-source-launcher/lib/DownloadRequiredModules.java"), List.of(
76+
Pattern.compile("final String version = \"" + VERSION_REGEX + '\"')
77+
));
7578
}
7679

7780
void update(Path path, List<Pattern> patterns) throws IOException {

0 commit comments

Comments
 (0)