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)` 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")