Skip to content

Commit c33b283

Browse files
authored
Introduce org.junit.start module
1 parent b0f79ae commit c33b283

File tree

28 files changed

+534
-89
lines changed

28 files changed

+534
-89
lines changed

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ val vintageProjects by extra(listOf(
4343
dependencyProject(projects.junitVintageEngine)
4444
))
4545

46-
val mavenizedProjects by extra(platformProjects + jupiterProjects + vintageProjects)
46+
val mavenizedProjects by extra(listOf(dependencyProject(projects.junitStart)) + platformProjects + jupiterProjects + vintageProjects)
4747
val modularProjects by extra(mavenizedProjects - setOf(dependencyProject(projects.junitPlatformConsoleStandalone)))
4848

4949
dependencies {

documentation/src/docs/asciidoc/link-attributes.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ endif::[]
230230
// Jupiter Migration Support
231231
:EnableJUnit4MigrationSupport: {javadoc-root}/org.junit.jupiter.migrationsupport/org/junit/jupiter/migrationsupport/EnableJUnit4MigrationSupport.html[@EnableJUnit4MigrationSupport]
232232
:EnableRuleMigrationSupport: {javadoc-root}/org.junit.jupiter.migrationsupport/org/junit/jupiter/migrationsupport/rules/EnableRuleMigrationSupport.html[@EnableRuleMigrationSupport]
233+
// JUnit Start
234+
:JUnit: {javadoc-root}/org.junit.start/org/junit/start/JUnit.html[JUnit]
233235
// Vintage
234236
:junit-vintage-engine: {javadoc-root}/org.junit.vintage.engine/org/junit/vintage/engine/package-summary.html[junit-vintage-engine]
235237
// Examples Repository

documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ repository on GitHub.
5959
[[release-notes-6.1.0-M1-junit-jupiter-new-features-and-improvements]]
6060
==== New Features and Improvements
6161

62+
* Introduce new module `org.junit.start` for writing and running tests. It simplifies
63+
using JUnit in compact source files together with a single module import statement:
6264
* Introduce new `dynamicTest(Consumer<? super Configuration>)` factory method for dynamic
6365
tests. It allows configuring the `ExecutionMode` of the dynamic test in addition to its
6466
display name, test source URI, and executable.

documentation/src/docs/asciidoc/user-guide/running-tests.adoc

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,39 @@ DYNAMIC = 35
836836
REPORTED = 37
837837
----
838838

839+
[[running-tests-source-launcher]]
840+
=== Source Launcher
841+
842+
Starting with Java 25 it is possible to write minimal source code test programs
843+
using the `org.junit.start` module. For example, like in a `HelloTests.java`
844+
file reading:
845+
846+
```java
847+
import module org.junit.start;
848+
849+
void main() {
850+
JUnit.run();
851+
}
852+
853+
@Test
854+
void stringLength() {
855+
Assertions.assertEquals(11, "Hello JUnit".length());
856+
}
857+
```
858+
With all required modular JAR files available in a local `lib/` directory, the
859+
following Java 25+ command will discover and execute tests using the JUnit Platform.
860+
It will also print the result tree to the console.
861+
862+
```shell
863+
java --module-path lib --add-modules org.junit.start HelloTests.java
864+
865+
└─ JUnit Jupiter ✔
866+
└─ HelloTests ✔
867+
└─ stringLength() ✔
868+
```
869+
870+
Find JUnit's class API documentation here: {JUnit}
871+
839872
[[running-tests-discovery-selectors]]
840873
=== Discovery Selectors
841874

documentation/src/javadoc/junit-overview.html

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<body>
22

3-
<p>This document consists of three sections:</p>
3+
<p>This document consists of four sections:</p>
44

55
<dl>
66
<dt>Platform</dt>
@@ -12,13 +12,16 @@
1212
</dd>
1313
<dt>Jupiter</dt>
1414
<dd>JUnit Jupiter is the combination of the programming model and extension model for
15-
writing JUnit tests and extensions. The Jupiter sub-project provides a TestEngine
15+
writing JUnit tests and extensions. The Jupiter subproject provides a TestEngine
1616
for running Jupiter based tests on the platform.
1717
</dd>
1818
<dt>Vintage</dt>
1919
<dd>JUnit Vintage provides a TestEngine for running JUnit 3 and JUnit 4 based tests on the
2020
platform.
2121
</dd>
22+
<dt>Other Modules</dt>
23+
<dd>This section lists all modules that are not part of a dedicated section.
24+
</dd>
2225
</dl>
2326

2427
<p>Already consulted the <a href="../user-guide/index.html">JUnit User Guide</a>?</p>

junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java

Lines changed: 0 additions & 48 deletions
This file was deleted.

junit-platform-commons/src/main/java/org/junit/platform/commons/util/DefaultClasspathScanner.java

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
package org.junit.platform.commons.util;
1212

1313
import static java.util.stream.Collectors.joining;
14-
import static org.junit.platform.commons.util.ClasspathFilters.CLASS_FILE_SUFFIX;
14+
import static org.junit.platform.commons.util.SearchPathUtils.PACKAGE_SEPARATOR_CHAR;
15+
import static org.junit.platform.commons.util.SearchPathUtils.PACKAGE_SEPARATOR_STRING;
16+
import static org.junit.platform.commons.util.SearchPathUtils.determineSimpleClassName;
1517
import static org.junit.platform.commons.util.StringUtils.isNotBlank;
1618

1719
import java.io.IOException;
@@ -57,8 +59,6 @@ class DefaultClasspathScanner implements ClasspathScanner {
5759
private static final char CLASSPATH_RESOURCE_PATH_SEPARATOR = '/';
5860
private static final String CLASSPATH_RESOURCE_PATH_SEPARATOR_STRING = String.valueOf(
5961
CLASSPATH_RESOURCE_PATH_SEPARATOR);
60-
private static final char PACKAGE_SEPARATOR_CHAR = '.';
61-
private static final String PACKAGE_SEPARATOR_STRING = String.valueOf(PACKAGE_SEPARATOR_CHAR);
6262

6363
/**
6464
* Malformed class name InternalError like reported in #401.
@@ -132,7 +132,7 @@ private List<Class<?>> findClassesForUris(List<URI> baseUris, String basePackage
132132
private List<Class<?>> findClassesForUri(URI baseUri, String basePackageName, ClassFilter classFilter) {
133133
List<Class<?>> classes = new ArrayList<>();
134134
// @formatter:off
135-
walkFilesForUri(baseUri, ClasspathFilters.classFiles(),
135+
walkFilesForUri(baseUri, SearchPathUtils::isClassOrSourceFile,
136136
(baseDir, file) ->
137137
processClassFileSafely(baseDir, basePackageName, classFilter, file, classes::add));
138138
// @formatter:on
@@ -156,7 +156,7 @@ private List<Resource> findResourcesForUris(List<URI> baseUris, String basePacka
156156
private List<Resource> findResourcesForUri(URI baseUri, String basePackageName, ResourceFilter resourceFilter) {
157157
List<Resource> resources = new ArrayList<>();
158158
// @formatter:off
159-
walkFilesForUri(baseUri, ClasspathFilters.resourceFiles(),
159+
walkFilesForUri(baseUri, SearchPathUtils::isResourceFile,
160160
(baseDir, file) ->
161161
processResourceFileSafely(baseDir, basePackageName, resourceFilter, file, resources::add));
162162
// @formatter:on
@@ -182,10 +182,10 @@ private static void walkFilesForUri(URI baseUri, Predicate<Path> filter, BiConsu
182182
}
183183
}
184184

185-
private void processClassFileSafely(Path baseDir, String basePackageName, ClassFilter classFilter, Path classFile,
185+
private void processClassFileSafely(Path baseDir, String basePackageName, ClassFilter classFilter, Path file,
186186
Consumer<Class<?>> classConsumer) {
187187
try {
188-
String fullyQualifiedClassName = determineFullyQualifiedClassName(baseDir, basePackageName, classFile);
188+
String fullyQualifiedClassName = determineFullyQualifiedClassName(baseDir, basePackageName, file);
189189
if (classFilter.match(fullyQualifiedClassName)) {
190190
try {
191191
// @formatter:off
@@ -196,12 +196,12 @@ private void processClassFileSafely(Path baseDir, String basePackageName, ClassF
196196
// @formatter:on
197197
}
198198
catch (InternalError internalError) {
199-
handleInternalError(classFile, fullyQualifiedClassName, internalError);
199+
handleInternalError(file, fullyQualifiedClassName, internalError);
200200
}
201201
}
202202
}
203203
catch (Throwable throwable) {
204-
handleThrowable(classFile, throwable);
204+
handleThrowable(file, throwable);
205205
}
206206
}
207207

@@ -221,12 +221,12 @@ private void processResourceFileSafely(Path baseDir, String basePackageName, Res
221221
}
222222
}
223223

224-
private String determineFullyQualifiedClassName(Path baseDir, String basePackageName, Path classFile) {
224+
private String determineFullyQualifiedClassName(Path baseDir, String basePackageName, Path file) {
225225
// @formatter:off
226226
return Stream.of(
227227
basePackageName,
228-
determineSubpackageName(baseDir, classFile),
229-
determineSimpleClassName(classFile)
228+
determineSubpackageName(baseDir, file),
229+
determineSimpleClassName(file)
230230
)
231231
.filter(value -> !value.isEmpty()) // Handle default package appropriately.
232232
.collect(joining(PACKAGE_SEPARATOR_STRING));
@@ -253,24 +253,14 @@ private String determineFullyQualifiedResourceName(Path baseDir, String basePack
253253
// @formatter:on
254254
}
255255

256-
private String determineSimpleClassName(Path classFile) {
257-
String fileName = classFile.getFileName().toString();
258-
return fileName.substring(0, fileName.length() - CLASS_FILE_SUFFIX.length());
259-
}
260-
261256
private String determineSimpleResourceName(Path resourceFile) {
262257
return resourceFile.getFileName().toString();
263258
}
264259

265-
private String determineSubpackageName(Path baseDir, Path classFile) {
266-
Path relativePath = baseDir.relativize(classFile.getParent());
260+
private String determineSubpackageName(Path baseDir, Path file) {
261+
Path relativePath = baseDir.relativize(file.getParent());
267262
String pathSeparator = baseDir.getFileSystem().getSeparator();
268-
String subpackageName = relativePath.toString().replace(pathSeparator, PACKAGE_SEPARATOR_STRING);
269-
if (subpackageName.endsWith(pathSeparator)) {
270-
// Workaround for JDK bug: https://bugs.openjdk.java.net/browse/JDK-8153248
271-
subpackageName = subpackageName.substring(0, subpackageName.length() - pathSeparator.length());
272-
}
273-
return subpackageName;
263+
return relativePath.toString().replace(pathSeparator, PACKAGE_SEPARATOR_STRING);
274264
}
275265

276266
private void handleInternalError(Path classFile, String fullyQualifiedClassName, InternalError ex) {

junit-platform-commons/src/main/java/org/junit/platform/commons/util/ExceptionUtils.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
@API(status = INTERNAL, since = "1.0")
4242
public final class ExceptionUtils {
4343

44+
private static final String JUNIT_START_PACKAGE_PREFIX = "org.junit.start.";
45+
4446
private static final String JUNIT_PLATFORM_LAUNCHER_PACKAGE_PREFIX = "org.junit.platform.launcher.";
4547

4648
private static final Predicate<String> STACK_TRACE_ELEMENT_FILTER = ClassNamePatternFilterUtils //
@@ -139,6 +141,9 @@ public static void pruneStackTrace(Throwable throwable, List<String> classNames)
139141
prunedStackTrace.addAll(stackTrace.subList(i, stackTrace.size()));
140142
break;
141143
}
144+
else if (className.startsWith(JUNIT_START_PACKAGE_PREFIX)) {
145+
prunedStackTrace.clear();
146+
}
142147
else if (className.startsWith(JUNIT_PLATFORM_LAUNCHER_PACKAGE_PREFIX)) {
143148
prunedStackTrace.clear();
144149
}

junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.lang.module.ResolvedModule;
2525
import java.net.URI;
2626
import java.net.URISyntaxException;
27+
import java.nio.file.Path;
2728
import java.util.ArrayList;
2829
import java.util.LinkedHashSet;
2930
import java.util.List;
@@ -253,9 +254,10 @@ List<Class<?>> scan(ModuleReference reference) {
253254
try (ModuleReader reader = reference.open()) {
254255
try (Stream<String> names = reader.list()) {
255256
// @formatter:off
256-
return names.filter(name -> name.endsWith(".class"))
257-
.map(this::className)
258-
.filter(name -> !"module-info".equals(name))
257+
return names.filter(name -> !name.endsWith("/")) // remove directories
258+
.map(Path::of)
259+
.filter(SearchPathUtils::isClassOrSourceFile)
260+
.map(SearchPathUtils::determineFullyQualifiedClassName)
259261
.filter(classFilter::match)
260262
.<Class<?>> map(this::loadClassUnchecked)
261263
.filter(classFilter::match)
@@ -268,15 +270,6 @@ List<Class<?>> scan(ModuleReference reference) {
268270
}
269271
}
270272

271-
/**
272-
* Convert resource name to binary class name.
273-
*/
274-
private String className(String resourceName) {
275-
resourceName = resourceName.substring(0, resourceName.length() - 6); // 6 = ".class".length()
276-
resourceName = resourceName.replace('/', '.');
277-
return resourceName;
278-
}
279-
280273
/**
281274
* Load class by its binary name.
282275
*
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
package org.junit.platform.commons.util;
12+
13+
import static java.util.stream.Collectors.joining;
14+
15+
import java.nio.file.Path;
16+
import java.util.stream.IntStream;
17+
18+
import org.junit.platform.commons.JUnitException;
19+
20+
/**
21+
* @since 1.11
22+
*/
23+
class SearchPathUtils {
24+
25+
static final char PACKAGE_SEPARATOR_CHAR = '.';
26+
static final String PACKAGE_SEPARATOR_STRING = String.valueOf(PACKAGE_SEPARATOR_CHAR);
27+
private static final char FILE_NAME_EXTENSION_SEPARATOR_CHAR = '.';
28+
29+
private static final String CLASS_FILE_SUFFIX = ".class";
30+
private static final String SOURCE_FILE_SUFFIX = ".java";
31+
32+
private static final String PACKAGE_INFO_FILE_NAME = "package-info";
33+
private static final String MODULE_INFO_FILE_NAME = "module-info";
34+
35+
// System property defined since Java 12: https://bugs.java/bugdatabase/JDK-8210877
36+
private static final boolean SOURCE_MODE = System.getProperty("jdk.launcher.sourcefile") != null;
37+
38+
static boolean isResourceFile(Path file) {
39+
return !isClassFile(file);
40+
}
41+
42+
static boolean isClassOrSourceFile(Path file) {
43+
var fileName = file.getFileName().toString();
44+
return isClassOrSourceFile(fileName) && !isModuleInfoOrPackageInfo(fileName);
45+
}
46+
47+
private static boolean isModuleInfoOrPackageInfo(String fileName) {
48+
var fileNameWithoutExtension = removeExtension(fileName);
49+
return PACKAGE_INFO_FILE_NAME.equals(fileNameWithoutExtension) //
50+
|| MODULE_INFO_FILE_NAME.equals(fileNameWithoutExtension);
51+
}
52+
53+
static String determineFullyQualifiedClassName(Path path) {
54+
var simpleClassName = determineSimpleClassName(path);
55+
var parent = path.getParent();
56+
return parent == null ? simpleClassName : joinPathNamesWithPackageSeparator(parent.resolve(simpleClassName));
57+
}
58+
59+
private static String joinPathNamesWithPackageSeparator(Path path) {
60+
return IntStream.range(0, path.getNameCount()) //
61+
.mapToObj(i -> path.getName(i).toString()) //
62+
.collect(joining(PACKAGE_SEPARATOR_STRING));
63+
}
64+
65+
static String determineSimpleClassName(Path file) {
66+
return removeExtension(file.getFileName().toString());
67+
}
68+
69+
private static String removeExtension(String fileName) {
70+
int lastDot = fileName.lastIndexOf(FILE_NAME_EXTENSION_SEPARATOR_CHAR);
71+
if (lastDot < 0) {
72+
throw new JUnitException("Expected file name with file extension, but got: " + fileName);
73+
}
74+
return fileName.substring(0, lastDot);
75+
}
76+
77+
private static boolean isClassOrSourceFile(String name) {
78+
return name.endsWith(CLASS_FILE_SUFFIX) || (SOURCE_MODE && name.endsWith(SOURCE_FILE_SUFFIX));
79+
}
80+
81+
private static boolean isClassFile(Path file) {
82+
return file.getFileName().toString().endsWith(CLASS_FILE_SUFFIX);
83+
}
84+
85+
private SearchPathUtils() {
86+
}
87+
}

0 commit comments

Comments
 (0)