diff --git a/build.gradle.kts b/build.gradle.kts
index ac2b1c43ec54..917c24b1805a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -43,7 +43,7 @@ val vintageProjects by extra(listOf(
dependencyProject(projects.junitVintageEngine)
))
-val mavenizedProjects by extra(platformProjects + jupiterProjects + vintageProjects)
+val mavenizedProjects by extra(listOf(dependencyProject(projects.junitStart)) + platformProjects + jupiterProjects + vintageProjects)
val modularProjects by extra(mavenizedProjects - setOf(dependencyProject(projects.junitPlatformConsoleStandalone)))
dependencies {
diff --git a/documentation/src/docs/asciidoc/link-attributes.adoc b/documentation/src/docs/asciidoc/link-attributes.adoc
index 002e9b0585b3..6fb540e747d6 100644
--- a/documentation/src/docs/asciidoc/link-attributes.adoc
+++ b/documentation/src/docs/asciidoc/link-attributes.adoc
@@ -230,6 +230,8 @@ endif::[]
// Jupiter Migration Support
:EnableJUnit4MigrationSupport: {javadoc-root}/org.junit.jupiter.migrationsupport/org/junit/jupiter/migrationsupport/EnableJUnit4MigrationSupport.html[@EnableJUnit4MigrationSupport]
:EnableRuleMigrationSupport: {javadoc-root}/org.junit.jupiter.migrationsupport/org/junit/jupiter/migrationsupport/rules/EnableRuleMigrationSupport.html[@EnableRuleMigrationSupport]
+// JUnit Start
+:JUnit: {javadoc-root}/org.junit.start/org/junit/start/JUnit.html[JUnit]
// Vintage
:junit-vintage-engine: {javadoc-root}/org.junit.vintage.engine/org/junit/vintage/engine/package-summary.html[junit-vintage-engine]
// Examples Repository
@@ -247,6 +249,8 @@ endif::[]
:Checkstyle: https://checkstyle.sourceforge.io[Checkstyle]
:DiscussionsQA: https://github.com/junit-team/junit-framework/discussions/categories/q-a[Q&A category on GitHub Discussions]
:Hamcrest: https://hamcrest.org/JavaHamcrest/[Hamcrest]
+:JEP511: https://openjdk.org/jeps/511[JEP 511]
+:JEP512: https://openjdk.org/jeps/512[JEP 512]
:Jimfs: https://google.github.io/jimfs/[Jimfs]
:Log4j: https://logging.apache.org/log4j/2.x/[Log4j]
:Log4j_JDK_Logging_Adapter: https://logging.apache.org/log4j/2.x/log4j-jul/index.html[Log4j JDK Logging Adapter]
diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc
index bd1bce04e35d..5a4d14221df3 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc
@@ -52,6 +52,17 @@ repository on GitHub.
[[release-notes-6.1.0-M1-junit-jupiter-new-features-and-improvements]]
==== New Features and Improvements
+* Introduce new module `org.junit.start` for writing and running tests. It simplifies
+ usages of JUnit in compact source files together with a single module import statement:
+[source,java,indent=0]
+----
+import module org.junit.start;
+
+void main() { JUnit.run(); }
+
+@Test
+void test() { Assertions.assertEquals(2, 1 + 1); }
+----
* Introduce new `dynamicTest(Consumer super Configuration>)` factory method for dynamic
tests. It allows configuring the `ExecutionMode` of the dynamic test in addition to its
display name, test source URI, and executable.
diff --git a/documentation/src/docs/asciidoc/user-guide/running-tests.adoc b/documentation/src/docs/asciidoc/user-guide/running-tests.adoc
index 01cb5d23a6fa..b02c5ccd882f 100644
--- a/documentation/src/docs/asciidoc/user-guide/running-tests.adoc
+++ b/documentation/src/docs/asciidoc/user-guide/running-tests.adoc
@@ -836,6 +836,40 @@ DYNAMIC = 35
REPORTED = 37
----
+[[running-tests-source-launcher]]
+=== Source Launcher
+
+Java 25 introduced Module Import Declarations with {JEP511} and Compact Source Files
+and Instance Methods with {JEP512}. The `org.junit.start` module is JUnit's "On-Ramp"
+enabler, allowing to write minimal source code programs. For example, like in a
+`HelloTests.java` file reading:
+
+```java
+import module org.junit.start;
+
+void main() {
+ JUnit.run();
+}
+
+@Test
+void stringLength() {
+ Assertions.assertEquals(11, "Hello JUnit".length());
+}
+```
+With all required modular JAR files available in a local `lib/` directory, the
+following Java 25+ command will discover and execute tests using the JUnit Platform.
+It will also print the result tree to the console.
+
+```shell
+java --module-path lib --add-modules org.junit.start HelloTests.java
+╷
+└─ JUnit Jupiter ✔
+ └─ HelloTests ✔
+ └─ stringLength() ✔
+```
+
+Find JUnit's class API documentation here: {JUnit}
+
[[running-tests-discovery-selectors]]
=== Discovery Selectors
diff --git a/documentation/src/javadoc/junit-overview.html b/documentation/src/javadoc/junit-overview.html
index 50837dbd73e6..5c496c12e1c7 100644
--- a/documentation/src/javadoc/junit-overview.html
+++ b/documentation/src/javadoc/junit-overview.html
@@ -1,6 +1,6 @@
-This document consists of three sections:
+This document consists of four sections:
- Platform
@@ -12,13 +12,16 @@
- Jupiter
- JUnit Jupiter is the combination of the programming model and extension model for
- writing JUnit tests and extensions. The Jupiter sub-project provides a TestEngine
+ writing JUnit tests and extensions. The Jupiter subproject provides a TestEngine
for running Jupiter based tests on the platform.
- Vintage
- JUnit Vintage provides a TestEngine for running JUnit 3 and JUnit 4 based tests on the
platform.
+ - Other Modules
+ - This section lists all modules that are not part of a dedicated section.
+
Already consulted the JUnit User Guide?
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java
deleted file mode 100644
index 1887ed7a5211..000000000000
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ClasspathFilters.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright 2015-2025 the original author or authors.
- *
- * All rights reserved. This program and the accompanying materials are
- * made available under the terms of the Eclipse Public License v2.0 which
- * accompanies this distribution and is available at
- *
- * https://www.eclipse.org/legal/epl-v20.html
- */
-
-package org.junit.platform.commons.util;
-
-import java.nio.file.Path;
-import java.util.function.Predicate;
-
-/**
- * @since 1.11
- */
-class ClasspathFilters {
-
- static final String CLASS_FILE_SUFFIX = ".class";
- private static final String PACKAGE_INFO_FILE_NAME = "package-info" + CLASS_FILE_SUFFIX;
- private static final String MODULE_INFO_FILE_NAME = "module-info" + CLASS_FILE_SUFFIX;
-
- static Predicate classFiles() {
- return file -> isNotPackageInfo(file) && isNotModuleInfo(file) && isClassFile(file);
- }
-
- static Predicate resourceFiles() {
- return file -> !isClassFile(file);
- }
-
- private static boolean isNotPackageInfo(Path path) {
- return !path.endsWith(PACKAGE_INFO_FILE_NAME);
- }
-
- private static boolean isNotModuleInfo(Path path) {
- return !path.endsWith(MODULE_INFO_FILE_NAME);
- }
-
- private static boolean isClassFile(Path file) {
- return file.getFileName().toString().endsWith(CLASS_FILE_SUFFIX);
- }
-
- private ClasspathFilters() {
- }
-
-}
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/DefaultClasspathScanner.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/DefaultClasspathScanner.java
index e7a66df2a44d..849c78cd654d 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/DefaultClasspathScanner.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/DefaultClasspathScanner.java
@@ -11,7 +11,9 @@
package org.junit.platform.commons.util;
import static java.util.stream.Collectors.joining;
-import static org.junit.platform.commons.util.ClasspathFilters.CLASS_FILE_SUFFIX;
+import static org.junit.platform.commons.util.SearchPathUtils.PACKAGE_SEPARATOR_CHAR;
+import static org.junit.platform.commons.util.SearchPathUtils.PACKAGE_SEPARATOR_STRING;
+import static org.junit.platform.commons.util.SearchPathUtils.determineSimpleClassName;
import static org.junit.platform.commons.util.StringUtils.isNotBlank;
import java.io.IOException;
@@ -57,8 +59,6 @@ class DefaultClasspathScanner implements ClasspathScanner {
private static final char CLASSPATH_RESOURCE_PATH_SEPARATOR = '/';
private static final String CLASSPATH_RESOURCE_PATH_SEPARATOR_STRING = String.valueOf(
CLASSPATH_RESOURCE_PATH_SEPARATOR);
- private static final char PACKAGE_SEPARATOR_CHAR = '.';
- private static final String PACKAGE_SEPARATOR_STRING = String.valueOf(PACKAGE_SEPARATOR_CHAR);
/**
* Malformed class name InternalError like reported in #401.
@@ -132,7 +132,7 @@ private List> findClassesForUris(List baseUris, String basePackage
private List> findClassesForUri(URI baseUri, String basePackageName, ClassFilter classFilter) {
List> classes = new ArrayList<>();
// @formatter:off
- walkFilesForUri(baseUri, ClasspathFilters.classFiles(),
+ walkFilesForUri(baseUri, SearchPathUtils::isClassOrSourceFile,
(baseDir, file) ->
processClassFileSafely(baseDir, basePackageName, classFilter, file, classes::add));
// @formatter:on
@@ -156,7 +156,7 @@ private List findResourcesForUris(List baseUris, String basePacka
private List findResourcesForUri(URI baseUri, String basePackageName, ResourceFilter resourceFilter) {
List resources = new ArrayList<>();
// @formatter:off
- walkFilesForUri(baseUri, ClasspathFilters.resourceFiles(),
+ walkFilesForUri(baseUri, SearchPathUtils::isResourceFile,
(baseDir, file) ->
processResourceFileSafely(baseDir, basePackageName, resourceFilter, file, resources::add));
// @formatter:on
@@ -182,10 +182,10 @@ private static void walkFilesForUri(URI baseUri, Predicate filter, BiConsu
}
}
- private void processClassFileSafely(Path baseDir, String basePackageName, ClassFilter classFilter, Path classFile,
+ private void processClassFileSafely(Path baseDir, String basePackageName, ClassFilter classFilter, Path file,
Consumer> classConsumer) {
try {
- String fullyQualifiedClassName = determineFullyQualifiedClassName(baseDir, basePackageName, classFile);
+ String fullyQualifiedClassName = determineFullyQualifiedClassName(baseDir, basePackageName, file);
if (classFilter.match(fullyQualifiedClassName)) {
try {
// @formatter:off
@@ -196,12 +196,12 @@ private void processClassFileSafely(Path baseDir, String basePackageName, ClassF
// @formatter:on
}
catch (InternalError internalError) {
- handleInternalError(classFile, fullyQualifiedClassName, internalError);
+ handleInternalError(file, fullyQualifiedClassName, internalError);
}
}
}
catch (Throwable throwable) {
- handleThrowable(classFile, throwable);
+ handleThrowable(file, throwable);
}
}
@@ -221,12 +221,12 @@ private void processResourceFileSafely(Path baseDir, String basePackageName, Res
}
}
- private String determineFullyQualifiedClassName(Path baseDir, String basePackageName, Path classFile) {
+ private String determineFullyQualifiedClassName(Path baseDir, String basePackageName, Path file) {
// @formatter:off
return Stream.of(
basePackageName,
- determineSubpackageName(baseDir, classFile),
- determineSimpleClassName(classFile)
+ determineSubpackageName(baseDir, file),
+ determineSimpleClassName(file)
)
.filter(value -> !value.isEmpty()) // Handle default package appropriately.
.collect(joining(PACKAGE_SEPARATOR_STRING));
@@ -253,24 +253,14 @@ private String determineFullyQualifiedResourceName(Path baseDir, String basePack
// @formatter:on
}
- private String determineSimpleClassName(Path classFile) {
- String fileName = classFile.getFileName().toString();
- return fileName.substring(0, fileName.length() - CLASS_FILE_SUFFIX.length());
- }
-
private String determineSimpleResourceName(Path resourceFile) {
return resourceFile.getFileName().toString();
}
- private String determineSubpackageName(Path baseDir, Path classFile) {
- Path relativePath = baseDir.relativize(classFile.getParent());
+ private String determineSubpackageName(Path baseDir, Path file) {
+ Path relativePath = baseDir.relativize(file.getParent());
String pathSeparator = baseDir.getFileSystem().getSeparator();
- String subpackageName = relativePath.toString().replace(pathSeparator, PACKAGE_SEPARATOR_STRING);
- if (subpackageName.endsWith(pathSeparator)) {
- // Workaround for JDK bug: https://bugs.openjdk.java.net/browse/JDK-8153248
- subpackageName = subpackageName.substring(0, subpackageName.length() - pathSeparator.length());
- }
- return subpackageName;
+ return relativePath.toString().replace(pathSeparator, PACKAGE_SEPARATOR_STRING);
}
private void handleInternalError(Path classFile, String fullyQualifiedClassName, InternalError ex) {
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ExceptionUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ExceptionUtils.java
index 350f443b3ad1..a236b89fc90e 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ExceptionUtils.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ExceptionUtils.java
@@ -41,6 +41,8 @@
@API(status = INTERNAL, since = "1.0")
public final class ExceptionUtils {
+ private static final String JUNIT_START_PACKAGE_PREFIX = "org.junit.start.";
+
private static final String JUNIT_PLATFORM_LAUNCHER_PACKAGE_PREFIX = "org.junit.platform.launcher.";
private static final Predicate STACK_TRACE_ELEMENT_FILTER = ClassNamePatternFilterUtils //
@@ -139,6 +141,9 @@ public static void pruneStackTrace(Throwable throwable, List classNames)
prunedStackTrace.addAll(stackTrace.subList(i, stackTrace.size()));
break;
}
+ else if (className.startsWith(JUNIT_START_PACKAGE_PREFIX)) {
+ prunedStackTrace.clear();
+ }
else if (className.startsWith(JUNIT_PLATFORM_LAUNCHER_PACKAGE_PREFIX)) {
prunedStackTrace.clear();
}
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java
index 9c0ae00120c4..ec682e6c1132 100644
--- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/ModuleUtils.java
@@ -24,6 +24,7 @@
import java.lang.module.ResolvedModule;
import java.net.URI;
import java.net.URISyntaxException;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@@ -253,9 +254,10 @@ List> scan(ModuleReference reference) {
try (ModuleReader reader = reference.open()) {
try (Stream names = reader.list()) {
// @formatter:off
- return names.filter(name -> name.endsWith(".class"))
- .map(this::className)
- .filter(name -> !"module-info".equals(name))
+ return names.filter(name -> !name.endsWith("/")) // remove directories
+ .map(Path::of)
+ .filter(SearchPathUtils::isClassOrSourceFile)
+ .map(SearchPathUtils::determineFullyQualifiedClassName)
.filter(classFilter::match)
.> map(this::loadClassUnchecked)
.filter(classFilter::match)
@@ -268,15 +270,6 @@ List> scan(ModuleReference reference) {
}
}
- /**
- * Convert resource name to binary class name.
- */
- private String className(String resourceName) {
- resourceName = resourceName.substring(0, resourceName.length() - 6); // 6 = ".class".length()
- resourceName = resourceName.replace('/', '.');
- return resourceName;
- }
-
/**
* Load class by its binary name.
*
diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/SearchPathUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/SearchPathUtils.java
new file mode 100644
index 000000000000..48eb38c1ed87
--- /dev/null
+++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/SearchPathUtils.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.commons.util;
+
+import static java.util.stream.Collectors.joining;
+
+import java.nio.file.Path;
+import java.util.stream.IntStream;
+
+import org.junit.platform.commons.JUnitException;
+
+/**
+ * @since 1.11
+ */
+class SearchPathUtils {
+
+ static final char PACKAGE_SEPARATOR_CHAR = '.';
+ static final String PACKAGE_SEPARATOR_STRING = String.valueOf(PACKAGE_SEPARATOR_CHAR);
+ private static final char FILE_NAME_EXTENSION_SEPARATOR_CHAR = '.';
+
+ private static final String CLASS_FILE_SUFFIX = ".class";
+ private static final String SOURCE_FILE_SUFFIX = ".java";
+
+ private static final String PACKAGE_INFO_FILE_NAME = "package-info";
+ private static final String MODULE_INFO_FILE_NAME = "module-info";
+
+ // System property defined since Java 12: https://bugs.java/bugdatabase/JDK-8210877
+ private static final boolean SOURCE_MODE = System.getProperty("jdk.launcher.sourcefile") != null;
+
+ static boolean isResourceFile(Path file) {
+ return !isClassFile(file);
+ }
+
+ static boolean isClassOrSourceFile(Path file) {
+ var fileName = file.getFileName().toString();
+ return isClassOrSourceFile(fileName) && !isModuleInfoOrPackageInfo(fileName);
+ }
+
+ private static boolean isModuleInfoOrPackageInfo(String fileName) {
+ var fileNameWithoutExtension = removeExtension(fileName);
+ return PACKAGE_INFO_FILE_NAME.equals(fileNameWithoutExtension) //
+ || MODULE_INFO_FILE_NAME.equals(fileNameWithoutExtension);
+ }
+
+ static String determineFullyQualifiedClassName(Path path) {
+ var simpleClassName = determineSimpleClassName(path);
+ var parent = path.getParent();
+ return parent == null ? simpleClassName : joinPathNamesWithPackageSeparator(parent.resolve(simpleClassName));
+ }
+
+ private static String joinPathNamesWithPackageSeparator(Path path) {
+ return IntStream.range(0, path.getNameCount()) //
+ .mapToObj(i -> path.getName(i).toString()) //
+ .collect(joining(PACKAGE_SEPARATOR_STRING));
+ }
+
+ static String determineSimpleClassName(Path file) {
+ return removeExtension(file.getFileName().toString());
+ }
+
+ private static String removeExtension(String fileName) {
+ int lastDot = fileName.lastIndexOf(FILE_NAME_EXTENSION_SEPARATOR_CHAR);
+ if (lastDot < 0) {
+ throw new JUnitException("Expected file name with file extension, but got: " + fileName);
+ }
+ return fileName.substring(0, lastDot);
+ }
+
+ private static boolean isClassOrSourceFile(String name) {
+ return name.endsWith(CLASS_FILE_SUFFIX) || (SOURCE_MODE && name.endsWith(SOURCE_FILE_SUFFIX));
+ }
+
+ private static boolean isClassFile(Path file) {
+ return file.getFileName().toString().endsWith(CLASS_FILE_SUFFIX);
+ }
+
+ private SearchPathUtils() {
+ }
+}
diff --git a/junit-platform-console/junit-platform-console.gradle.kts b/junit-platform-console/junit-platform-console.gradle.kts
index 803d76a1da1c..dd9568faf82f 100644
--- a/junit-platform-console/junit-platform-console.gradle.kts
+++ b/junit-platform-console/junit-platform-console.gradle.kts
@@ -27,6 +27,7 @@ dependencies {
tasks {
compileJava {
options.compilerArgs.addAll(listOf(
+ "-Xlint:-module", // due to qualified exports
"--add-modules", "info.picocli",
"--add-reads", "${javaModuleName}=info.picocli"
))
diff --git a/junit-platform-console/src/main/java/module-info.java b/junit-platform-console/src/main/java/module-info.java
index 336c201f8252..7ca71d3518dd 100644
--- a/junit-platform-console/src/main/java/module-info.java
+++ b/junit-platform-console/src/main/java/module-info.java
@@ -24,5 +24,7 @@
requires org.junit.platform.launcher;
requires org.junit.platform.reporting;
+ exports org.junit.platform.console.output to org.junit.start;
+
provides java.util.spi.ToolProvider with org.junit.platform.console.ConsoleLauncherToolProvider;
}
diff --git a/junit-start/junit-start.gradle.kts b/junit-start/junit-start.gradle.kts
new file mode 100644
index 000000000000..8b03e5986e21
--- /dev/null
+++ b/junit-start/junit-start.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+ id("junitbuild.java-library-conventions")
+}
+
+description = "JUnit Start Module"
+
+dependencies {
+ api(platform(projects.junitBom))
+ api(projects.junitJupiter)
+
+ compileOnlyApi(libs.apiguardian)
+ compileOnlyApi(libs.jspecify)
+ compileOnlyApi(projects.junitJupiterEngine)
+
+ implementation(projects.junitPlatformLauncher)
+ implementation(projects.junitPlatformConsole)
+}
+
+backwardCompatibilityChecks {
+ enabled = false // TODO enable after initial release
+}
diff --git a/junit-start/src/main/java/module-info.java b/junit-start/src/main/java/module-info.java
new file mode 100644
index 000000000000..971e4ffa2123
--- /dev/null
+++ b/junit-start/src/main/java/module-info.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+
+/// Defines the API of the JUnit Start module for writing and running tests.
+///
+/// Usage example:
+/// ```java
+/// import module org.junit.start;
+///
+/// void main() {
+/// JUnit.run();
+/// }
+///
+/// @Test
+/// void addition() {
+/// Assertions.assertEquals(2, 1 + 1, "Addition error detected!");
+/// }
+/// }
+/// ```
+module org.junit.start {
+ requires static transitive org.apiguardian.api;
+ requires static transitive org.jspecify;
+
+ requires transitive org.junit.jupiter;
+ requires org.junit.platform.launcher;
+ requires org.junit.platform.console;
+
+ exports org.junit.start;
+}
diff --git a/junit-start/src/main/java/org/junit/start/JUnit.java b/junit-start/src/main/java/org/junit/start/JUnit.java
new file mode 100644
index 000000000000..e170004e33b3
--- /dev/null
+++ b/junit-start/src/main/java/org/junit/start/JUnit.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.start;
+
+import static java.lang.StackWalker.Option.RETAIN_CLASS_REFERENCE;
+import static org.apiguardian.api.API.Status.EXPERIMENTAL;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectModule;
+import static org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder.request;
+
+import java.io.PrintWriter;
+import java.nio.charset.Charset;
+
+import org.apiguardian.api.API;
+import org.junit.platform.commons.JUnitException;
+import org.junit.platform.console.output.ColorPalette;
+import org.junit.platform.console.output.Theme;
+import org.junit.platform.console.output.TreePrintingListener;
+import org.junit.platform.engine.DiscoverySelector;
+import org.junit.platform.launcher.core.LauncherFactory;
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
+
+/// This class provides simple helpers to discover and execute tests.
+@API(status = EXPERIMENTAL, since = "6.1")
+public final class JUnit {
+ /// Run all tests defined in the caller class.
+ public static void run() {
+ var walker = StackWalker.getInstance(RETAIN_CLASS_REFERENCE);
+ run(selectClass(walker.getCallerClass()));
+ }
+
+ /// Run all tests defined in the given test class.
+ /// @param testClass the class to discover and execute tests in
+ public static void run(Class> testClass) {
+ run(selectClass(testClass));
+ }
+
+ /// Run all tests defined in the given module.
+ /// @param testModule the module to discover and execute tests in
+ public static void run(Module testModule) {
+ run(selectModule(testModule));
+ }
+
+ private static void run(DiscoverySelector selector) {
+ var listener = new SummaryGeneratingListener();
+ var charset = Charset.defaultCharset();
+ var writer = new PrintWriter(System.out, true, charset);
+ var palette = System.getenv("NO_COLOR") != null ? ColorPalette.NONE : ColorPalette.DEFAULT;
+ var theme = Theme.valueOf(charset);
+ var printer = new TreePrintingListener(writer, palette, theme);
+ var request = request().selectors(selector).forExecution() //
+ .listeners(listener, printer) //
+ .build();
+ var launcher = LauncherFactory.create();
+ launcher.execute(request);
+ var summary = listener.getSummary();
+
+ if (summary.getTotalFailureCount() == 0)
+ return;
+
+ summary.printFailuresTo(new PrintWriter(System.err, true, charset));
+ throw new JUnitException("JUnit run finished with %d failure%s".formatted( //
+ summary.getTotalFailureCount(), //
+ summary.getTotalFailureCount() == 1 ? "" : "s"));
+ }
+
+ private JUnit() {
+ }
+}
diff --git a/junit-start/src/main/java/org/junit/start/package-info.java b/junit-start/src/main/java/org/junit/start/package-info.java
new file mode 100644
index 000000000000..1d7b5a9feb98
--- /dev/null
+++ b/junit-start/src/main/java/org/junit/start/package-info.java
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+/// Contains JUnit Start API for writing and running tests.
+
+@NullMarked
+package org.junit.start;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/platform-tooling-support-tests/platform-tooling-support-tests.gradle.kts b/platform-tooling-support-tests/platform-tooling-support-tests.gradle.kts
index 57115677d812..46681ff3d45c 100644
--- a/platform-tooling-support-tests/platform-tooling-support-tests.gradle.kts
+++ b/platform-tooling-support-tests/platform-tooling-support-tests.gradle.kts
@@ -20,6 +20,7 @@ javaLibrary {
spotless {
java {
target(files(project.java.sourceSets.map { it.allJava }), "projects/**/*.java")
+ targetExclude("projects/junit-start/**/*.java") // due to compact source files and module imports
}
kotlin {
target("projects/**/*.kt")
diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-console.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-console.expected.txt
index 1309f73a2f35..870d9bd85bd8 100644
--- a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-console.expected.txt
+++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-console.expected.txt
@@ -7,9 +7,9 @@ requires org.junit.platform.engine
requires org.junit.platform.launcher
requires org.junit.platform.reporting
provides java.util.spi.ToolProvider with org.junit.platform.console.ConsoleLauncherToolProvider
+qualified exports org.junit.platform.console.output to org.junit.start
contains org.junit.platform.console
contains org.junit.platform.console.command
contains org.junit.platform.console.options
-contains org.junit.platform.console.output
contains org.junit.platform.console.shadow.picocli
main-class org.junit.platform.console.ConsoleLauncher
diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-start.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-start.expected.txt
new file mode 100644
index 000000000000..0c4ee8aa39b8
--- /dev/null
+++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-start.expected.txt
@@ -0,0 +1,8 @@
+org.junit.start@${version} jar:file:.+/junit-start-\d.+\.jar..module-info\.class
+exports org.junit.start
+requires java.base mandated
+requires org.apiguardian.api static transitive
+requires org.jspecify static transitive
+requires org.junit.jupiter transitive
+requires org.junit.platform.console
+requires org.junit.platform.launcher
diff --git a/platform-tooling-support-tests/projects/junit-start/compact/JUnitRun.java b/platform-tooling-support-tests/projects/junit-start/compact/JUnitRun.java
new file mode 100644
index 000000000000..8d38641fb08f
--- /dev/null
+++ b/platform-tooling-support-tests/projects/junit-start/compact/JUnitRun.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+import module org.junit.start;
+
+void main() {
+ JUnit.run();
+}
+
+@Test
+void addition() {
+ Assertions.assertEquals(2, 1 + 1, "Addition error detected!");
+}
diff --git a/platform-tooling-support-tests/projects/junit-start/compact/JUnitRunClass.java b/platform-tooling-support-tests/projects/junit-start/compact/JUnitRunClass.java
new file mode 100644
index 000000000000..86e76c0d6523
--- /dev/null
+++ b/platform-tooling-support-tests/projects/junit-start/compact/JUnitRunClass.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+import module org.junit.start;
+
+void main() {
+ JUnit.run(getClass());
+}
+
+@Test
+void substraction() {
+ Assertions.assertEquals(2, 3 - 1, "Subtraction error detected!");
+}
diff --git a/platform-tooling-support-tests/projects/junit-start/modular/module-info.java b/platform-tooling-support-tests/projects/junit-start/modular/module-info.java
new file mode 100644
index 000000000000..9f29b2f31b7f
--- /dev/null
+++ b/platform-tooling-support-tests/projects/junit-start/modular/module-info.java
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+open module m {
+ requires org.junit.start;
+}
diff --git a/platform-tooling-support-tests/projects/junit-start/modular/p/JUnitRunModule.java b/platform-tooling-support-tests/projects/junit-start/modular/p/JUnitRunModule.java
new file mode 100644
index 000000000000..bde03f682da9
--- /dev/null
+++ b/platform-tooling-support-tests/projects/junit-start/modular/p/JUnitRunModule.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package p;
+
+import module org.junit.start;
+
+class JUnitRunModule {
+ void main() {
+ JUnit.run(getClass().getModule());
+ }
+}
diff --git a/platform-tooling-support-tests/projects/junit-start/modular/p/MultiplicationTests.java b/platform-tooling-support-tests/projects/junit-start/modular/p/MultiplicationTests.java
new file mode 100644
index 000000000000..af41e24566b3
--- /dev/null
+++ b/platform-tooling-support-tests/projects/junit-start/modular/p/MultiplicationTests.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package p;
+
+import module org.junit.jupiter.api;
+
+class MultiplicationTests {
+
+ @Test
+ void multiplication() {
+ Assertions.assertEquals(4, 2 * 2, "Multiplication error detected!");
+ }
+}
diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/HelperTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/HelperTests.java
index d755f73cd5e8..1d0dc9dea053 100644
--- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/HelperTests.java
+++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/HelperTests.java
@@ -36,6 +36,7 @@ void loadModuleDirectoryNames() {
"junit-jupiter-engine", //
"junit-jupiter-migrationsupport", //
"junit-jupiter-params", //
+ "junit-start", //
"junit-platform-commons", //
"junit-platform-console", //
"junit-platform-engine", //
diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/JUnitStartTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/JUnitStartTests.java
new file mode 100644
index 000000000000..777bc2a91eef
--- /dev/null
+++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/JUnitStartTests.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2015-2025 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package platform.tooling.support.tests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static platform.tooling.support.tests.Projects.copyToWorkspace;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnJre;
+import org.junit.jupiter.api.condition.JRE;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.platform.tests.process.OutputFiles;
+
+import platform.tooling.support.Helper;
+import platform.tooling.support.MavenRepo;
+import platform.tooling.support.ProcessStarters;
+import platform.tooling.support.ThirdPartyJars;
+
+/**
+ * @since 6.1
+ */
+class JUnitStartTests {
+
+ @TempDir
+ static Path workspace;
+
+ @BeforeAll
+ static void prepareLocalLibraryDirectoryWithJUnitModules() throws Exception {
+ copyToWorkspace(Projects.JUNIT_start, workspace);
+ var lib = workspace.resolve("lib");
+ try {
+ Files.createDirectories(lib);
+ try (var directoryStream = Files.newDirectoryStream(lib, "*.jar")) {
+ for (Path jarFile : directoryStream) {
+ Files.delete(jarFile);
+ }
+ }
+ for (var module : Helper.loadModuleDirectoryNames()) {
+ if (module.startsWith("junit-platform") || module.startsWith("junit-jupiter")
+ || module.equals("junit-start")) {
+ if (module.equals("junit-jupiter-migrationsupport"))
+ continue;
+ if (module.startsWith("junit-platform-suite"))
+ continue;
+ if (module.equals("junit-platform-testkit"))
+ continue;
+ var jar = MavenRepo.jar(module);
+ Files.copy(jar, lib.resolve(module + ".jar"));
+ }
+ }
+ ThirdPartyJars.copy(lib, "org.apiguardian", "apiguardian-api");
+ ThirdPartyJars.copy(lib, "org.jspecify", "jspecify");
+ ThirdPartyJars.copy(lib, "org.opentest4j", "opentest4j");
+ ThirdPartyJars.copy(lib, "org.opentest4j.reporting", "open-test-reporting-tooling-spi");
+ }
+ catch (Exception e) {
+ throw new AssertionError("Preparing local library folder failed", e);
+ }
+ }
+
+ @Test
+ @EnabledOnJre(JRE.JAVA_25)
+ void junitRun(@FilePrefix("junit-run") OutputFiles outputFiles) throws Exception {
+ var result = ProcessStarters.java() //
+ .workingDir(workspace) //
+ .addArguments("--module-path", "lib") // relative to workspace
+ .addArguments("--add-modules", "org.junit.start") // configure root module
+ .addArguments("compact/JUnitRun.java") // leverage Java's source mode
+ .redirectOutput(outputFiles) //
+ .startAndWait();
+
+ assertEquals(0, result.exitCode());
+ assertTrue(result.stdOut().contains("addition()"), result.stdOut());
+ }
+
+ @Test
+ @EnabledOnJre(JRE.JAVA_25)
+ void junitRunClass(@FilePrefix("junit-run-class") OutputFiles outputFiles) throws Exception {
+ var result = ProcessStarters.java() //
+ .workingDir(workspace) //
+ .addArguments("--module-path", "lib") // relative to workspace
+ .addArguments("--add-modules", "org.junit.start") // configure root module
+ .addArguments("compact/JUnitRunClass.java") // leverage Java's source mode
+ .redirectOutput(outputFiles) //
+ .startAndWait();
+
+ assertEquals(0, result.exitCode());
+ assertTrue(result.stdOut().contains("substraction()"), result.stdOut());
+ }
+
+ @Test
+ @EnabledOnJre(JRE.JAVA_25)
+ void junitRunModule(@FilePrefix("junit-run-module") OutputFiles outputFiles) throws Exception {
+ var result = ProcessStarters.java() //
+ .workingDir(workspace) //
+ .putEnvironment("NO_COLOR", "1") // --disable-ansi-colors
+ .addArguments("--module-path", "lib") // relative to workspace
+ .addArguments("modular/p/JUnitRunModule.java") // leverage Java's source mode
+ .redirectOutput(outputFiles) //
+ .startAndWait();
+
+ assertEquals(0, result.exitCode());
+ assertTrue(result.stdOut().contains("multiplication()"), result.stdOut());
+ }
+
+}
diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java
index be645466ee34..b62b9ca99cf4 100644
--- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java
+++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java
@@ -21,6 +21,7 @@ public class Projects {
public static final String GRADLE_KOTLIN_EXTENSIONS = "gradle-kotlin-extensions";
public static final String GRADLE_MISSING_ENGINE = "gradle-missing-engine";
public static final String JAR_DESCRIBE_MODULE = "jar-describe-module";
+ public static final String JUNIT_start = "junit-start";
public static final String JUPITER_STARTER = "jupiter-starter";
public static final String KOTLIN_COROUTINES = "kotlin-coroutines";
public static final String MAVEN_SUREFIRE_COMPATIBILITY = "maven-surefire-compatibility";
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 53dc8d7c4f78..22cff4977cde 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -79,6 +79,7 @@ include("junit-jupiter-api")
include("junit-jupiter-engine")
include("junit-jupiter-migrationsupport")
include("junit-jupiter-params")
+include("junit-start")
include("junit-platform-commons")
include("junit-platform-console")
include("junit-platform-console-standalone")