This is an automated email from the ASF dual-hosted git repository.
ashishvijaywargiya pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git
The following commit(s) were added to refs/heads/trunk by this push:
new c13e47a7a2 JUnit3 to JUnit5(Jupiter) Runner Migration for OFBiz Test
Cases/ Integration Tests (#1529)
c13e47a7a2 is described below
commit c13e47a7a27e89c6ce66c4592047ebfc33c91b66
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Fri Jul 31 10:22:45 2026 +0530
JUnit3 to JUnit5(Jupiter) Runner Migration for OFBiz Test Cases/
Integration Tests (#1529)
JUnit3 to JUnit5(Jupiter) Runner Migration for OFBiz Test Cases/
Integration Tests
1) Added JupiterTestExtension.java, whose JupiterTestSuite adapts a
Jupiter test class to the JUnit 3 Test contract so it runs through the
real JUnit Platform Launcher yet reports through TestRunContainer's
existing TestResult/TestListener/XML pipeline, side-by-side with
junit-test-suite test-cases in the same test-suite.
2) Added JupiterTestHelper.java, a mixin interface giving
getDelegator()/getDispatcher()/getUserLogin()/from()/select() with no
field or constructor boilerplate, reading JupiterTestExtension's
package-private ThreadLocal delegator/dispatcher bridge.
3) Added JunitJupiterTest.java, a composed annotation
(@Tag("jupiterIntegration") + @ExtendWith(JupiterTestExtension.class))
paired with a build.gradle excludeTags 'jupiterIntegration' change, so
Jupiter integration classes are invisible to plain gradlew test but
still run under testIntegration via a new <jupiter-test-suite
class-name="..."> testdef element.
4) Extended framework/testtools/dtd/test-suite.xsd with the
jupiter-test-suite element/attribute group, and wired
ModelTestSuite.java to parse it, instantiate a JupiterTestSuite, and
inject the suite's Delegator/LocalDispatcher exactly like it already
does for junit-test-suite.
5) JupiterTestExtension pins the method orderer to
MethodOrderer.OrderAnnotation and forces
junit.jupiter.execution.parallel.enabled=false for every suite,
replacing JUnit 5's own unordered-by-default and parallel-capable
defaults so the ThreadLocal delegator/dispatcher bridge is always read
on the correct thread in a predictable order.
6) Added JupiterInjectionGuardsTest.java, unit-testing that
delegator/dispatcher injection fails fast with a clear message instead
of a silent null/NPE when a class runs outside the container or opts
into unsupported parallel execution.
7) Refactored EntityTestCase.java/OFBizTestCase.java to expose shared
static getUserLogin/from/select EntityQuery helpers, so JUnit 3's
OFBizTestCase and JUnit 5's JupiterTestHelper both delegate to one
implementation instead of duplicating EntityQuery logic.
8) Adding JUnit 5 support does not stop existing JUnit 3 test cases from
running - both continue to run side by side without any issues; with
every Integration Test in the plugins folder now migrated to
Jupiter(creating separate PR for this), the plan is to let these JUnit
5-based Integration Tests soak for the next few days before starting the
migration of the remaining JUnit 3 Integration Tests in the applications
folder.
---
build.gradle | 4 +-
dependencies.gradle | 6 +-
.../ofbiz/entity/testtools/EntityTestCase.java | 62 ++++
.../ofbiz/service/testtools/OFBizTestCase.java | 13 +-
framework/testtools/dtd/test-suite.xsd | 25 ++
.../apache/ofbiz/testtools/JunitJupiterTest.java | 60 +++
.../ofbiz/testtools/JupiterTestExtension.java | 401 +++++++++++++++++++++
.../apache/ofbiz/testtools/JupiterTestHelper.java | 165 +++++++++
.../org/apache/ofbiz/testtools/ModelTestSuite.java | 15 +
.../testtools/JupiterInjectionGuardsTest.java | 212 +++++++++++
10 files changed, 952 insertions(+), 11 deletions(-)
diff --git a/build.gradle b/build.gradle
index dde39621c9..a676d084f7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -374,7 +374,9 @@ eclipse.classpath.file.whenMerged { classpath ->
tasks.eclipse.dependsOn(cleanEclipse)
test {
- useJUnitPlatform()
+ useJUnitPlatform {
+ excludeTags 'jupiterIntegration' // see
JupiterTestExtension.INTEGRATION_TAG
+ }
jvmArgs "-javaagent:${classpath.find { it.name.contains('jmockit')
}.absolutePath}"
// Required for JMockit instrumentation, Mockito, and Spring Test under
Java 21
jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED'
diff --git a/dependencies.gradle b/dependencies.gradle
index 5b945852f8..2dcb81c432 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -93,8 +93,10 @@ dependencies {
exclude group: 'xpp3', module: 'xpp3'
}
- testImplementation 'org.junit.jupiter:junit-jupiter-api:6.1.0'
- testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.1.0'
+ implementation 'org.junit.jupiter:junit-jupiter-api:6.1.0'
+ implementation 'org.junit.platform:junit-platform-launcher:6.1.0'
+ runtimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.1.0'
+
testImplementation 'org.junit.jupiter:junit-jupiter-params:6.1.0'
testImplementation 'org.junit.platform:junit-platform-launcher:6.1.0'
testImplementation 'org.hamcrest:hamcrest-library:2.2'
diff --git
a/framework/entity/src/main/java/org/apache/ofbiz/entity/testtools/EntityTestCase.java
b/framework/entity/src/main/java/org/apache/ofbiz/entity/testtools/EntityTestCase.java
index bff685c56f..e3b8c16c79 100644
---
a/framework/entity/src/main/java/org/apache/ofbiz/entity/testtools/EntityTestCase.java
+++
b/framework/entity/src/main/java/org/apache/ofbiz/entity/testtools/EntityTestCase.java
@@ -19,7 +19,13 @@
package org.apache.ofbiz.entity.testtools;
+import java.util.Set;
+
import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.model.DynamicViewEntity;
+import org.apache.ofbiz.entity.util.EntityQuery;
import junit.framework.TestCase;
@@ -46,4 +52,60 @@ public class EntityTestCase extends TestCase {
public Delegator getDelegator() {
return delegator;
}
+
+ /**
+ * Gets user login. Shared by OFBizTestCase (JUnit 3) and
JupiterTestHelper (JUnit 5) so the
+ * actual EntityQuery logic exists in exactly one place; each side
supplies its own Delegator
+ * (instance field vs. ThreadLocal) and keeps its own bare-call wrapper.
+ * @param delegator the delegator
+ * @param userLoginId the user login id
+ * @return the user login
+ * @throws GenericEntityException the generic entity exception
+ */
+ public static GenericValue getUserLogin(Delegator delegator, String
userLoginId) throws GenericEntityException {
+ return EntityQuery.use(delegator)
+ .from("UserLogin")
+ .where("userLoginId", userLoginId)
+ .queryOne();
+ }
+
+ /**
+ * From entity query.
+ * @param delegator the delegator
+ * @param entityName the entity name
+ * @return the entity query
+ */
+ public static EntityQuery from(Delegator delegator, String entityName) {
+ return EntityQuery.use(delegator).from(entityName);
+ }
+
+ /**
+ * From entity query.
+ * @param delegator the delegator
+ * @param dynamicViewEntity the dynamic view entity
+ * @return the entity query
+ */
+ public static EntityQuery from(Delegator delegator, DynamicViewEntity
dynamicViewEntity) {
+ return EntityQuery.use(delegator).from(dynamicViewEntity);
+ }
+
+ /**
+ * Select entity query.
+ * @param delegator the delegator
+ * @param fields the fields
+ * @return the entity query
+ */
+ public static EntityQuery select(Delegator delegator, String... fields) {
+ return EntityQuery.use(delegator).select(fields);
+ }
+
+ /**
+ * Select entity query.
+ * @param delegator the delegator
+ * @param fields the fields
+ * @return the entity query
+ */
+ public static EntityQuery select(Delegator delegator, Set<String> fields) {
+ return EntityQuery.use(delegator).select(fields);
+ }
}
diff --git
a/framework/service/src/main/java/org/apache/ofbiz/service/testtools/OFBizTestCase.java
b/framework/service/src/main/java/org/apache/ofbiz/service/testtools/OFBizTestCase.java
index 4a78841147..595ba346b8 100644
---
a/framework/service/src/main/java/org/apache/ofbiz/service/testtools/OFBizTestCase.java
+++
b/framework/service/src/main/java/org/apache/ofbiz/service/testtools/OFBizTestCase.java
@@ -61,10 +61,7 @@ public class OFBizTestCase extends EntityTestCase {
* @throws GenericEntityException the generic entity exception
*/
protected GenericValue getUserLogin(String userLoginId) throws
GenericEntityException {
- return EntityQuery.use(getDelegator())
- .from("UserLogin")
- .where("userLoginId", userLoginId)
- .queryOne();
+ return EntityTestCase.getUserLogin(getDelegator(), userLoginId);
}
/**
@@ -82,7 +79,7 @@ public class OFBizTestCase extends EntityTestCase {
* @return the entity query
*/
protected EntityQuery from(String entityName) {
- return EntityQuery.use(getDelegator()).from(entityName);
+ return EntityTestCase.from(getDelegator(), entityName);
}
/**
@@ -91,7 +88,7 @@ public class OFBizTestCase extends EntityTestCase {
* @return the entity query
*/
protected EntityQuery from(DynamicViewEntity dynamicViewEntity) {
- return EntityQuery.use(getDelegator()).from(dynamicViewEntity);
+ return EntityTestCase.from(getDelegator(), dynamicViewEntity);
}
/**
@@ -100,7 +97,7 @@ public class OFBizTestCase extends EntityTestCase {
* @return the entity query
*/
protected EntityQuery select(String... fields) {
- return EntityQuery.use(getDelegator()).select(fields);
+ return EntityTestCase.select(getDelegator(), fields);
}
/**
@@ -109,7 +106,7 @@ public class OFBizTestCase extends EntityTestCase {
* @return the entity query
*/
protected EntityQuery select(Set<String> fields) {
- return EntityQuery.use(getDelegator()).select(fields);
+ return EntityTestCase.select(getDelegator(), fields);
}
/**
diff --git a/framework/testtools/dtd/test-suite.xsd
b/framework/testtools/dtd/test-suite.xsd
index 74d6b71d11..c737761dd5 100644
--- a/framework/testtools/dtd/test-suite.xsd
+++ b/framework/testtools/dtd/test-suite.xsd
@@ -89,6 +89,31 @@ under the License.
</xs:attribute>
</xs:attributeGroup>
+ <xs:element name="jupiter-test-suite" substitutionGroup="TestCaseTypes">
+ <xs:annotation>
+ <xs:documentation>
+ Used for JUnit 5 (Jupiter) test classes, run through the JUnit
Platform Launcher and
+ reported through the same pipeline as junit-test-suite. Runs
alongside junit-test-suite
+ test-cases in the same test-suite, sharing its
Delegator/LocalDispatcher. Test classes
+ that need a real Delegator or LocalDispatcher should add
+ @ExtendWith(JupiterTestExtension.class) and declare them as
method parameters.
+ </xs:documentation>
+ </xs:annotation>
+ <xs:complexType>
+ <xs:attributeGroup ref="attlist.jupiter-test-suite"/>
+ </xs:complexType>
+ </xs:element>
+ <xs:attributeGroup name="attlist.jupiter-test-suite">
+ <xs:attribute type="xs:string" name="class-name" use="required">
+ <xs:annotation>
+ <xs:documentation>
+ A plain JUnit 5 Jupiter test class (no
junit.framework.TestCase inheritance,
+ no (String name) constructor - just
@Test/@ParameterizedTest/@Disabled methods).
+ </xs:documentation>
+ </xs:annotation>
+ </xs:attribute>
+ </xs:attributeGroup>
+
<xs:element name="service-test" substitutionGroup="TestCaseTypes">
<xs:complexType>
<xs:attributeGroup ref="attlist.service-test"/>
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JunitJupiterTest.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JunitJupiterTest.java
new file mode 100644
index 0000000000..4e8f656dcb
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JunitJupiterTest.java
@@ -0,0 +1,60 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.testtools;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * Marks a Jupiter test class as running only through the ofbiz --test
container
+ * (testIntegration), excluded from plain gradlew test. Combines
+ * {@literal @}Tag(JupiterTestExtension.INTEGRATION_TAG) - read by
build.gradle's
+ * excludeTags filter, so gradlew test never discovers the class at all - with
+ * {@literal @}ExtendWith(JupiterTestExtension.class), which remains as a
runtime
+ * safety net (see JupiterTestExtension's evaluateExecutionCondition()) for
any class
+ * that reaches JUnit Platform discovery without this annotation's tag having
excluded
+ * it first, e.g. a class using {@literal
@}ExtendWith(JupiterTestExtension.class)
+ * directly instead of this composed annotation.
+ *
+ * <p>This annotation does not, by itself, register a class with
testIntegration -
+ * that still requires a {@code <jupiter-test-suite class-name="...">} entry
in the
+ * component's testdef XML. A class carrying only this annotation, with no
matching
+ * testdef entry, runs nowhere: excluded from gradlew test by tag, and never
picked up
+ * by ModelTestSuite for testIntegration either.
+ *
+ * <p>Running {@code gradlew test --tests} against one of these classes fails
the build
+ * with "No tests found for given includes" rather than reporting a skip,
since the tag
+ * excludes it from discovery before Gradle's {@code --tests} filter ever sees
it; use
+ * {@code gradlew testIntegration} or {@code ofbiz --test} instead. An
IDE-native run
+ * that bypasses Gradle's test task entirely still reports a clean skip via
the runtime
+ * condition.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Tag(JupiterTestExtension.INTEGRATION_TAG)
+@ExtendWith(JupiterTestExtension.class)
+public @interface JunitJupiterTest {
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
new file mode 100644
index 0000000000..b58fcebdec
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
@@ -0,0 +1,401 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.testtools;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.service.LocalDispatcher;
+import org.junit.jupiter.api.extension.ConditionEvaluationResult;
+import org.junit.jupiter.api.extension.ExecutionCondition;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+import org.junit.jupiter.api.extension.ParameterResolver;
+import org.junit.jupiter.api.extension.TestInstancePostProcessor;
+import org.junit.platform.engine.TestExecutionResult;
+import org.junit.platform.launcher.Launcher;
+import org.junit.platform.launcher.LauncherDiscoveryRequest;
+import org.junit.platform.launcher.TestExecutionListener;
+import org.junit.platform.launcher.TestIdentifier;
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
+import org.junit.platform.launcher.core.LauncherFactory;
+
+import junit.framework.AssertionFailedError;
+import junit.framework.Test;
+import junit.framework.TestCase;
+import junit.framework.TestResult;
+
+import static
org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+
+/**
+ * Injects the per-suite Delegator/LocalDispatcher that ModelTestSuite already
builds for JUnit 3
+ * test-cases into Jupiter test classes run through JupiterTestSuite.
+ *
+ * <p>The recommended pattern - the one both reference examples use ({@code
ExampleTests} and
+ * {@code ExampleJupiterTests} in {@code plugins/example/.../test}) - is to
{@code implements
+ * JupiterTestHelper} and call its
getDelegator()/getDispatcher()/getUserLogin()/from()/select()
+ * directly: no field, no method parameter, nothing to declare in the test
class at all. That
+ * interface's default methods read this extension's
CURRENT_DELEGATOR/CURRENT_DISPATCHER
+ * ThreadLocals directly (see its javadoc), which is also why it works
unchanged inside
+ * {@literal @}ParameterizedTest methods - getDispatcher() isn't one of the
method's declared
+ * parameters, so there's no interaction with {@literal @}CsvSource (or any
other argument source)
+ * ordering at all. See {@code shouldCreateExampleAcrossTypes} in {@code
ExampleJupiterTests},
+ * which combines a {@literal @}CsvSource-provided {@code String} with a
getDispatcher() call.
+ *
+ * <p>Two lower-level mechanisms remain available for classes that can't rely
on
+ * {@code JupiterTestHelper}:
+ *
+ * <ul>
+ * <li>declare a "delegator"/"dispatcher" field (any visibility - a bare
Groovy property
+ * declaration is enough) and it is set once per test instance via
postProcessTestInstance() below,
+ * the same idea as JUnit 3's EntityTestCase getting Delegator/LocalDispatcher
through
+ * post-construction setDelegator()/setDispatcher() calls; or</li>
+ * <li>declare a Delegator/LocalDispatcher method parameter directly, resolved
via
+ * resolveParameter() below.</li>
+ * </ul>
+ *
+ * Both are wired from the same suite-scoped values as JupiterTestHelper, so
either can be mixed in
+ * on the same class if needed. The method-parameter style is needed rather
than merely optional in
+ * one case: {@literal @}BeforeAll/{@literal @}AfterAll are static, so they
run with no test
+ * instance for postProcessTestInstance() to inject a field into, or for a
default interface method
+ * to be called on; a method parameter, resolved per-invocation, is the only
way to reach
+ * Delegator/LocalDispatcher there.
+ *
+ * <p>When a {@literal @}ParameterizedTest method does mix {@literal
@}CsvSource-provided arguments
+ * with a method parameter resolved by this extension
(Delegator/LocalDispatcher) rather than
+ * JupiterTestHelper, the CSV-provided parameters must come first in the
method signature: JUnit 5
+ * fills them left-to-right, then resolves the remaining parameters via
registered
+ * ParameterResolvers.
+ *
+ * <p>JupiterTestSuite.run() executes tests synchronously on the calling
thread. This is pinned, not
+ * merely assumed of Jupiter's default: the discovery request built in
JupiterTestSuite's
+ * constructor sets {@code
configurationParameter("junit.jupiter.execution.parallel.enabled",
+ * "false")} on the {@code LauncherDiscoveryRequest} itself, which is the
highest-precedence
+ * configuration source in the JUnit Platform - it wins over a {@code
junit-platform.properties}
+ * file, a JVM system property, or any future Gradle test-task configuration,
so none of those can
+ * silently re-enable parallelism out from under this extension. That
configurationParameter must
+ * not be removed: without it, a test method could be dispatched to a worker
thread other than the
+ * one launcher.execute() was called from, and the plain ThreadLocal set
immediately before that
+ * call - which is what all three hooks below (postProcessTestInstance(),
resolveParameter(),
+ * evaluateExecutionCondition()) read CURRENT_DELEGATOR/CURRENT_DISPATCHER
from - is invisible to
+ * any other thread.
+ *
+ * <p><b>Classes run outside the container are skipped, not failed.</b>
+ * evaluateExecutionCondition() below disables any class extended with this
extension - via
+ * {@literal @}JunitJupiterTest or a bare {@literal
@}ExtendWith(JupiterTestExtension) - whose
+ * CURRENT_DELEGATOR/CURRENT_DISPATCHER ThreadLocals are unset. Under plain
{@code gradlew test},
+ * {@literal @}JunitJupiterTest classes are already excluded before discovery
by their tag (see
+ * build.gradle's excludeTags), so this condition is the safety net for the
paths that filter doesn't
+ * cover: a class using bare {@literal
@}ExtendWith(JupiterTestExtension.class) instead of
+ * {@literal @}JunitJupiterTest, and an IDE-native test run that bypasses
Gradle's test task
+ * entirely. This turns what would otherwise be a NullPointerException deep in
test logic
+ * (JupiterTestHelper's default methods) or the IllegalStateException/
+ * ParameterResolutionException thrown by the two hooks below into a reported
skip with an
+ * actionable reason. Those two hooks' exceptions remain in place as a safety
net for a genuine
+ * in-container misconfiguration; they are simply unreachable for the
outside-the-container case
+ * now that the class never gets that far.
+ *
+ * <p><b>Not per-test isolation.</b> JUnit 5 creates a fresh test instance per
{@literal @}Test
+ * method by default, which can suggest each method also gets a fresh
Delegator/LocalDispatcher -
+ * it doesn't. The Delegator/LocalDispatcher injected here are the single
instances
+ * ModelTestSuite.prepareTest() builds once for the whole {@code
<test-suite>}, shared across every
+ * test method and every Jupiter/JUnit 3 class in that suite, exactly as JUnit
3 test-cases already
+ * share them today. TestRunContainer rolls back all accumulated mutations
once, after the entire
+ * suite finishes - not per test method - so a test can observe data created
by an earlier test in
+ * the same suite, and ordering between test-cases in the suite's testdef XML
can matter.
+ */
+public class JupiterTestExtension implements ParameterResolver,
TestInstancePostProcessor, ExecutionCondition {
+
+ /** Read by build.gradle's `test` task ({@code excludeTags}) and by {@link
JunitJupiterTest}. */
+ public static final String INTEGRATION_TAG = "jupiterIntegration";
+
+ static final ThreadLocal<Delegator> CURRENT_DELEGATOR = new
ThreadLocal<>();
+ static final ThreadLocal<LocalDispatcher> CURRENT_DISPATCHER = new
ThreadLocal<>();
+
+ /**
+ * Disables classes/methods run outside the ofbiz --test container instead
of letting them reach
+ * postProcessTestInstance()/resolveParameter() (or, for
JupiterTestHelper-based classes, a
+ * NullPointerException from a getDelegator()/getDispatcher() caller).
Both ThreadLocals are
+ * checked rather than just one so a class relying on only a Delegator or
only a
+ * LocalDispatcher isn't disabled by a coincidentally-unset ThreadLocal it
never actually reads
+ * - in practice JupiterTestSuite.run() arms both together.
+ */
+ @Override
+ public ConditionEvaluationResult
evaluateExecutionCondition(ExtensionContext extensionContext) {
+ if (CURRENT_DELEGATOR.get() == null && CURRENT_DISPATCHER.get() ==
null) {
+ return ConditionEvaluationResult.disabled(
+ "Requires the ofbiz --test container
(Delegator/LocalDispatcher not armed on this "
+ + "thread). Run via 'gradlew testIntegration' or
'ofbiz --test', not plain "
+ + "'gradlew test'.");
+ }
+ return ConditionEvaluationResult.enabled("Delegator/LocalDispatcher
available.");
+ }
+
+ @Override
+ public void postProcessTestInstance(Object testInstance, ExtensionContext
extensionContext) throws Exception {
+ injectField(testInstance, "delegator", Delegator.class,
CURRENT_DELEGATOR.get());
+ injectField(testInstance, "dispatcher", LocalDispatcher.class,
CURRENT_DISPATCHER.get());
+ }
+
+ @Override
+ public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
+ Class<?> type = parameterContext.getParameter().getType();
+ return type == Delegator.class || type == LocalDispatcher.class;
+ }
+
+ @Override
+ public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
+ Class<?> type = parameterContext.getParameter().getType();
+ Object value = type == Delegator.class ? CURRENT_DELEGATOR.get() :
CURRENT_DISPATCHER.get();
+ if (value == null) {
+ throw new
ParameterResolutionException(unavailableMessage(type.getSimpleName(),
+ "parameter '" + parameterContext.getParameter().getName()
+ "'"));
+ }
+ return value;
+ }
+
+ private static void injectField(Object testInstance, String fieldName,
Class<?> fieldType, Object value) throws IllegalAccessException {
+ Field field = null;
+ for (Class<?> clz = testInstance.getClass(); clz != null; clz =
clz.getSuperclass()) {
+ Field candidate = declaredFieldOrNull(clz, fieldName);
+ if (candidate != null &&
fieldType.isAssignableFrom(candidate.getType())) {
+ field = candidate;
+ break;
+ }
+ }
+ if (field == null) {
+ Field mismatch = findAnyFieldOfType(testInstance.getClass(),
fieldType);
+ if (mismatch != null) {
+ throw new IllegalStateException("Field '" + mismatch.getName()
+ "' in " + testInstance.getClass().getName()
+ + " is of type " + fieldType.getSimpleName() + ", but
field injection only recognizes a field "
+ + "named exactly '" + fieldName + "'. Rename it to '"
+ fieldName + "', or implement "
+ + "JupiterTestHelper instead (type-based, no field
name required).");
+ }
+ return;
+ }
+ if (value == null) {
+ throw new
IllegalStateException(unavailableMessage(fieldType.getSimpleName(),
+ "field '" + fieldName + "' of " +
testInstance.getClass().getName()));
+ }
+ field.setAccessible(true);
+ field.set(testInstance, value);
+ }
+
+ /**
+ * Backstop for Concern 3 (name-literal field injection is otherwise
silent on a typo): finds any field of the
+ * right type regardless of name, so injectField() can fail loudly with
the actual field name and the required
+ * one, instead of leaving a misnamed field null with no indication
injection was ever attempted.
+ */
+ private static Field findAnyFieldOfType(Class<?> testClass, Class<?>
fieldType) {
+ for (Class<?> clz = testClass; clz != null; clz = clz.getSuperclass())
{
+ for (Field field : clz.getDeclaredFields()) {
+ if (fieldType.isAssignableFrom(field.getType())) {
+ return field;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Both injection points (field and parameter) reach here only when the
caller has explicitly asked for a
+ * Delegator/LocalDispatcher - by declaring the field or parameter - so a
null ThreadLocal value here is always a
+ * misconfiguration, not a legitimate "test doesn't need it" case. Failing
fast at the injection site turns what
+ * would otherwise be a mystery NPE deep in test logic into an error that
points at the actual cause.
+ */
+ private static String unavailableMessage(String typeName, String target) {
+ return "No " + typeName + " available to inject into " + target + ".
JupiterTestExtension's ThreadLocal "
+ + "bridge is only populated on the thread that calls
JupiterTestSuite.run(), and only for classes "
+ + "run through the ofbiz --test container (jupiter-test-suite
in a testdef XML). This is null "
+ + "because either this class ran outside that container (e.g.
plain gradlew test), or JUnit 5 "
+ + "parallel execution is enabled for it - both unsupported for
delegator/dispatcher injection.";
+ }
+
+ private static Field declaredFieldOrNull(Class<?> clz, String fieldName) {
+ try {
+ return clz.getDeclaredField(fieldName);
+ } catch (NoSuchFieldException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Adapts a JUnit 5 Jupiter test class to the JUnit 3 junit.framework.Test
contract so it can run
+ * inside TestRunContainer/ModelTestSuite side-by-side with
junit-test-suite (JUnit 3) test-cases,
+ * sharing the same suite-level Delegator/LocalDispatcher and reporting
through the same
+ * TestResult/TestListener/XML pipeline TestRunContainer already has.
Execution goes through the
+ * real JUnit Platform Launcher, so @Test/@ParameterizedTest/@Disabled
behave exactly as they
+ * would under `./gradlew test`. The discovery request pins the default
method orderer to
+ * {@code MethodOrderer.OrderAnnotation}, replacing Jupiter's own
unordered default so a class's
+ * execution order is always whatever its {@code @Order} annotations say
(or unspecified only
+ * among methods that declare none) rather than an unpredictable per-run
default. It also pins
+ * {@code junit.jupiter.execution.parallel.enabled} to {@code false}, so
every test method
+ * executes on the calling thread regardless of any system property or
properties file that
+ * might otherwise request parallelism - see the class-level javadoc above
for why that matters
+ * to the ThreadLocal bridge.
+ */
+ static final class JupiterTestSuite implements Test {
+
+ private static final String MODULE = JupiterTestSuite.class.getName();
+ private static final Pattern INDEX_SUFFIX =
Pattern.compile("(.*)\\[\\d+\\]$");
+
+ private final Class<?> testClass;
+ private final Launcher launcher;
+ private final LauncherDiscoveryRequest request;
+ private final int testCaseCount;
+ // Stored, not applied immediately: ModelTestSuite.prepareTest() calls
setDelegator()/
+ // setDispatcher() once for every JupiterTestSuite in a <test-suite>,
before any of them run.
+ // Pushing straight to the shared ThreadLocal there would let one
instance's post-run cleanup
+ // (see run() below) wipe state a sibling instance still needs.
Applying them in run() instead
+ // means each instance re-arms its own state right before it executes.
+ private Delegator delegator;
+ private LocalDispatcher dispatcher;
+
+ JupiterTestSuite(Class<?> testClass) {
+ this.testClass = testClass;
+ this.launcher = LauncherFactory.create();
+ this.request = LauncherDiscoveryRequestBuilder.request()
+ .selectors(selectClass(testClass))
+ .configurationParameter(
+ "junit.jupiter.testmethod.order.default",
+
"org.junit.jupiter.api.MethodOrderer$OrderAnnotation")
+
.configurationParameter("junit.jupiter.execution.parallel.enabled", "false")
+ .build();
+ this.testCaseCount = (int)
launcher.discover(request).countTestIdentifiers(TestIdentifier::isTest);
+ }
+
+ void setDelegator(Delegator delegator) {
+ this.delegator = delegator;
+ }
+
+ void setDispatcher(LocalDispatcher dispatcher) {
+ this.dispatcher = dispatcher;
+ }
+
+ @Override
+ public int countTestCases() {
+ return testCaseCount;
+ }
+
+ @Override
+ public void run(TestResult result) {
+ JupiterTestExtension.CURRENT_DELEGATOR.set(delegator);
+ JupiterTestExtension.CURRENT_DISPATCHER.set(dispatcher);
+ Map<String, Test> leafTests = new HashMap<>();
+ try {
+ launcher.execute(request, new TestExecutionListener() {
+ @Override
+ public void executionStarted(TestIdentifier
testIdentifier) {
+ if (testIdentifier.isTest()) {
+ Test leaf = new
JupiterLeafTest(reportingName(testIdentifier), testClass.getName());
+ leafTests.put(testIdentifier.getUniqueId(), leaf);
+ result.startTest(leaf);
+ }
+ }
+
+ @Override
+ public void executionSkipped(TestIdentifier
testIdentifier, String reason) {
+ if (testIdentifier.isTest()) {
+ Debug.logInfo("[JUNIT] SKIPPED: " +
testIdentifier.getDisplayName()
+ + " (" + testClass.getName() + ") - " +
reason, MODULE);
+ }
+ }
+
+ @Override
+ public void executionFinished(TestIdentifier
testIdentifier, TestExecutionResult testExecutionResult) {
+ if (!testIdentifier.isTest()) {
+ return;
+ }
+ Test leaf =
leafTests.get(testIdentifier.getUniqueId());
+ testExecutionResult.getThrowable().ifPresent(throwable
-> {
+ if (throwable instanceof AssertionError) {
+ result.addFailure(leaf, new
AssertionFailedError(throwable.getMessage()));
+ } else {
+ result.addError(leaf, throwable);
+ }
+ });
+ result.endTest(leaf);
+ }
+ });
+ } finally {
+ JupiterTestExtension.CURRENT_DELEGATOR.remove();
+ JupiterTestExtension.CURRENT_DISPATCHER.remove();
+ }
+ }
+
+ /**
+ * getLegacyReportingName() reports plain @Test methods as
"methodName(ParamType1, ParamType2)"
+ * and @ParameterizedTest invocations as "methodName(ParamType1,
ParamType2)[index]" - the
+ * parameter types come from JUnit 5's own default display name, not
from anything meaningful to
+ * a report reader here (they're always the
JupiterTestExtension-injected Delegator/LocalDispatcher,
+ * or CSV-provided arguments already visible elsewhere in the name).
Stripping them leaves plain
+ * JUnit 3 test methods ("testCreateExample") and Jupiter ones
("shouldCreateExample") looking
+ * consistent. For @ParameterizedTest invocations, the bare "[index]"
from getLegacyReportingName()
+ * is replaced with the test's own @ParameterizedTest(name=...)
display text (e.g. "[1] exampleTypeId=CONTRIVED"
+ * becomes "shouldCreateExampleAcrossTypes[exampleTypeId=CONTRIVED]"),
so each row is identifiable
+ * without needing to click into it.
+ */
+ private static String reportingName(TestIdentifier testIdentifier) {
+ String withoutParamTypes =
testIdentifier.getLegacyReportingName().replaceAll("\\([^)]*\\)", "");
+ Matcher indexSuffix = INDEX_SUFFIX.matcher(withoutParamTypes);
+ if (!indexSuffix.matches()) {
+ return withoutParamTypes;
+ }
+ String invocationLabel =
testIdentifier.getDisplayName().replaceFirst("^\\[\\d+]\\s*", "");
+ return indexSuffix.group(1) + "[" + invocationLabel + "]";
+ }
+
+ /**
+ * Extends junit.framework.TestCase (not a bare Test implementation)
so Ant's
+ * XMLJUnitResultFormatter resolves the reporting name through
JUnitVersionHelper's
+ * {@code instanceof TestCase} branch: a Method handle fixed once, at
class-init, to
+ * {@code TestCase.class.getMethod("getName")} - the stable, public
JUnit 3 API this file
+ * already depends on - rather than the duck-typed {@code
t.getClass().getMethod("getName")}
+ * fallback used for arbitrary Test implementors. That fallback is why
this class doesn't need
+ * to be public: the resolved Method's declaring class is TestCase, so
reflection.invoke()
+ * succeeds regardless of this nested class's own visibility.
+ */
+ static final class JupiterLeafTest extends TestCase {
+ private final String className;
+
+ JupiterLeafTest(String name, String className) {
+ super(name);
+ this.className = className;
+ }
+
+ @Override
+ public void run(TestResult result) {
+ throw new UnsupportedOperationException("JupiterLeafTest is a
reporting handle only, it cannot be run directly");
+ }
+
+ @Override
+ public String toString() {
+ return getName() + "(" + className + ")";
+ }
+ }
+
+ }
+
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestHelper.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestHelper.java
new file mode 100644
index 0000000000..18372ed51c
--- /dev/null
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestHelper.java
@@ -0,0 +1,165 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.testtools;
+
+import java.util.Set;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.model.DynamicViewEntity;
+import org.apache.ofbiz.entity.testtools.EntityTestCase;
+import org.apache.ofbiz.entity.util.EntityQuery;
+import org.apache.ofbiz.service.LocalDispatcher;
+
+/**
+ * Bare-call helpers ported from OFBizTestCase/EntityTestCase for JUnit 5
Jupiter test classes.
+ * Implement this on a {@literal @}JunitJupiterTest test class (Java or
+ * Groovy) to get getUserLogin(), from()/select(),
getDelegator()/getDispatcher(), and
+ * logInfo/logError/logWarning with no field, no method parameter, and no
inheritance required.
+ * Reads JupiterTestExtension.CURRENT_DELEGATOR/CURRENT_DISPATCHER directly -
legal because this
+ * interface shares JupiterTestExtension's package (package-private access).
+ *
+ * <p>Every delegator/dispatcher-backed method here - getUserLogin(), from(),
select(),
+ * getDelegator(), getDispatcher() - reads
CURRENT_DELEGATOR/CURRENT_DISPATCHER, which are only
+ * armed on the thread that {@code JupiterTestSuite.run()} executes on.
(logInfo/logError/
+ * logWarning read no ThreadLocal at all; they just delegate to {@code
Debug.log*} with
+ * {@code getClass().getName()}.) Relying on these ThreadLocals is safe under
the current,
+ * single-threaded, synchronous execution model (the default JUnit Platform
mode, and the only one
+ * {@code JupiterTestSuite}/{@code TestRunContainer} support). Under that
model, a class run
+ * outside the container still reaches these methods only via a ThreadLocal
that reads back
+ * {@code null} - unless {@code JupiterTestExtension}'s {@code
evaluateExecutionCondition()} gets a
+ * chance to disable it first, reporting a skip instead of letting a null
ThreadLocal read through
+ * into a confusing NPE. That still happens for a class using bare
+ * {@code @ExtendWith(JupiterTestExtension.class)} instead of {@literal
@}JunitJupiterTest
+ * (no tag, so plain {@code gradlew test} still discovers and then
runtime-skips it), and for an
+ * IDE-native run that bypasses Gradle's test task entirely (Gradle's
excludeTags filter never gets
+ * a chance to apply). A {@literal @}JunitJupiterTest class run via plain
{@code gradlew test}
+ * doesn't even get that far: build.gradle's excludeTags filter excludes it
from discovery by its
+ * tag before evaluateExecutionCondition() would ever run. That protection
does not extend to
+ * JUnit 5 parallel execution, which remains unsupported: a test class that
opts into it runs on a
+ * worker thread where the ThreadLocal is unset even when genuinely inside the
container, and these
+ * methods will again silently operate on/return {@code null}.
+ */
+public interface JupiterTestHelper {
+
+ /**
+ * Gets delegator. Ported from EntityTestCase's getDelegator() for API
symmetry - from()/
+ * select()/getUserLogin() above already cover the common cases, so this
is only needed when a
+ * test calls something that takes a raw Delegator directly (e.g.
delegator.makeValue(...)).
+ * @return the delegator
+ */
+ default Delegator getDelegator() {
+ return JupiterTestExtension.CURRENT_DELEGATOR.get();
+ }
+
+ /**
+ * Gets dispatcher, with no field or method parameter declaration needed
in the implementing
+ * class - call it directly wherever a test needs to invoke a service (e.g.
+ * getDispatcher().runSync(...)). JupiterTestExtension's field-injection
and
+ * ParameterResolver-based method-parameter styles remain available as
alternatives (see its
+ * javadoc), but are only necessary for classes that don't implement
JupiterTestHelper, or for
+ * static {@literal @}BeforeAll/{@literal @}AfterAll methods where there
is no test instance to
+ * call this default method on.
+ * @return the dispatcher
+ */
+ default LocalDispatcher getDispatcher() {
+ return JupiterTestExtension.CURRENT_DISPATCHER.get();
+ }
+
+ /**
+ * Gets user login.
+ * @param userLoginId the user login id
+ * @return the user login
+ * @throws GenericEntityException the generic entity exception
+ */
+ default GenericValue getUserLogin(String userLoginId) throws
GenericEntityException {
+ return EntityTestCase.getUserLogin(getDelegator(), userLoginId);
+ }
+
+ /**
+ * Gets user login.
+ * @return the user login
+ * @throws GenericEntityException the generic entity exception
+ */
+ default GenericValue getUserLogin() throws GenericEntityException {
+ return getUserLogin("system");
+ }
+
+ /**
+ * From entity query.
+ * @param entityName the entity name
+ * @return the entity query
+ */
+ default EntityQuery from(String entityName) {
+ return EntityTestCase.from(getDelegator(), entityName);
+ }
+
+ /**
+ * From entity query.
+ * @param dynamicViewEntity the dynamic view entity
+ * @return the entity query
+ */
+ default EntityQuery from(DynamicViewEntity dynamicViewEntity) {
+ return EntityTestCase.from(getDelegator(), dynamicViewEntity);
+ }
+
+ /**
+ * Select entity query.
+ * @param fields the fields
+ * @return the entity query
+ */
+ default EntityQuery select(String... fields) {
+ return EntityTestCase.select(getDelegator(), fields);
+ }
+
+ /**
+ * Select entity query.
+ * @param fields the fields
+ * @return the entity query
+ */
+ default EntityQuery select(Set<String> fields) {
+ return EntityTestCase.select(getDelegator(), fields);
+ }
+
+ /**
+ * Log info.
+ * @param msg the msg
+ */
+ default void logInfo(String msg) {
+ Debug.logInfo(msg, getClass().getName());
+ }
+
+ /**
+ * Log error.
+ * @param msg the msg
+ */
+ default void logError(String msg) {
+ Debug.logError(msg, getClass().getName());
+ }
+
+ /**
+ * Log warning.
+ * @param msg the msg
+ */
+ default void logWarning(String msg) {
+ Debug.logWarning(msg, getClass().getName());
+ }
+}
diff --git
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/ModelTestSuite.java
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/ModelTestSuite.java
index 63784d7ddc..8ba4a8f48d 100644
---
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/ModelTestSuite.java
+++
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/ModelTestSuite.java
@@ -38,6 +38,7 @@ import org.apache.ofbiz.minilang.SimpleMethod;
import org.apache.ofbiz.service.LocalDispatcher;
import org.apache.ofbiz.service.ServiceContainer;
import org.apache.ofbiz.service.testtools.OFBizTestCase;
+import org.apache.ofbiz.testtools.JupiterTestExtension.JupiterTestSuite;
import org.w3c.dom.Element;
import junit.framework.Test;
@@ -124,6 +125,15 @@ public class ModelTestSuite {
Debug.logError(e, MODULE);
}
}
+ } else if ("jupiter-test-suite".equals(nodeName)) {
+ String className = testElement.getAttribute("class-name");
+ try {
+ Class<?> clz = ObjectType.loadClass(className);
+ this.testList.add(new JupiterTestSuite(clz));
+ Debug.logInfo("Added Jupiter test class: " + className,
MODULE);
+ } catch (Exception e) {
+ Debug.logError(e, "Unable to load jupiter test suite class : "
+ className, MODULE);
+ }
} else if ("webdriver-test".equals(nodeName)) {
try {
String className = "org.apache.ofbiz.testtools.WebDriverTest";
@@ -197,6 +207,11 @@ public class ModelTestSuite {
// CHECKSTYLE_ON: ALMOST_ALL
} else if (test instanceof GroovyScriptAssert) {
prepareGroovyScriptAssert((GroovyScriptAssert) test);
+ } else if (test instanceof JupiterTestSuite) {
+ // CHECKSTYLE_OFF: ALMOST_ALL
+ ((JupiterTestSuite) test).setDelegator(delegator);
+ ((JupiterTestSuite) test).setDispatcher(dispatcher);
+ // CHECKSTYLE_ON: ALMOST_ALL
}
}
diff --git
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterInjectionGuardsTest.java
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterInjectionGuardsTest.java
new file mode 100644
index 0000000000..77bdc9a82e
--- /dev/null
+++
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterInjectionGuardsTest.java
@@ -0,0 +1,212 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.testtools;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.service.LocalDispatcher;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+
+import junit.framework.TestResult;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.everyItem;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.sameInstance;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Exercises the fail-fast guards added to JupiterTestExtension directly
(Concerns 2 and 3 of the
+ * community discussion), without needing the full ofbiz --test container: the
ThreadLocal bridge
+ * and field/parameter reflection are set up and torn down by hand here.
+ */
+class JupiterInjectionGuardsTest {
+
+ private final JupiterTestExtension extension = new JupiterTestExtension();
+
+ private static final String PARALLEL_ENABLED =
"junit.jupiter.execution.parallel.enabled";
+ private static final String PARALLEL_MODE_DEFAULT =
"junit.jupiter.execution.parallel.mode.default";
+
+ @AfterEach
+ void clearThreadLocals() {
+ JupiterTestExtension.CURRENT_DELEGATOR.remove();
+ JupiterTestExtension.CURRENT_DISPATCHER.remove();
+ }
+
+ @Test
+ void correctlyNamedFieldsGetInjected() throws Exception {
+ Delegator delegator = mock(Delegator.class);
+ LocalDispatcher dispatcher = mock(LocalDispatcher.class);
+ JupiterTestExtension.CURRENT_DELEGATOR.set(delegator);
+ JupiterTestExtension.CURRENT_DISPATCHER.set(dispatcher);
+
+ CorrectlyNamedFields instance = new CorrectlyNamedFields();
+ extension.postProcessTestInstance(instance, null);
+
+ assertThat(instance.delegator, sameInstance(delegator));
+ assertThat(instance.dispatcher, sameInstance(dispatcher));
+ }
+
+ @Test
+ void classWithNoDelegatorOrDispatcherFieldsIsUntouched() {
+ JupiterTestExtension.CURRENT_DELEGATOR.set(mock(Delegator.class));
+
JupiterTestExtension.CURRENT_DISPATCHER.set(mock(LocalDispatcher.class));
+
+ assertDoesNotThrow(() -> extension.postProcessTestInstance(new
NoRelevantFields(), null));
+ }
+
+ @Test
+ void misnamedDelegatorFieldFailsFastInsteadOfSilentlySkipping() {
+ JupiterTestExtension.CURRENT_DELEGATOR.set(mock(Delegator.class));
+
+ IllegalStateException thrown =
assertThrows(IllegalStateException.class, () ->
+ extension.postProcessTestInstance(new
MisnamedDelegatorField(), null));
+
+ assertThat(thrown.getMessage(), containsString("myDelegator"));
+ assertThat(thrown.getMessage(), containsString("'delegator'"));
+ }
+
+ @Test
+ void namedFieldWithNoAvailableDelegatorFailsFastInsteadOfLeavingItNull() {
+ // CURRENT_DELEGATOR intentionally left unset, simulating running
outside the ofbiz --test
+ // container or on a worker thread under (unsupported) parallel
execution.
+ IllegalStateException thrown =
assertThrows(IllegalStateException.class, () ->
+ extension.postProcessTestInstance(new CorrectlyNamedFields(),
null));
+
+ assertThat(thrown.getMessage(), containsString("delegator"));
+ }
+
+ @Test
+ void parameterResolutionFailsFastWhenThreadLocalIsUnset() throws Exception
{
+ Method dummy = ParameterFixtures.class.getDeclaredMethod("dummy",
Delegator.class);
+ Parameter delegatorParameter = dummy.getParameters()[0];
+ ParameterContext parameterContext = mock(ParameterContext.class);
+ when(parameterContext.getParameter()).thenReturn(delegatorParameter);
+
+ ParameterResolutionException thrown =
assertThrows(ParameterResolutionException.class, () ->
+ extension.resolveParameter(parameterContext, null));
+
+ assertThat(thrown, instanceOf(ParameterResolutionException.class));
+ }
+
+ @Test
+ void parameterResolutionSucceedsWhenThreadLocalIsSet() throws Exception {
+ Delegator delegator = mock(Delegator.class);
+ JupiterTestExtension.CURRENT_DELEGATOR.set(delegator);
+ Method dummy = ParameterFixtures.class.getDeclaredMethod("dummy",
Delegator.class);
+ Parameter delegatorParameter = dummy.getParameters()[0];
+ ParameterContext parameterContext = mock(ParameterContext.class);
+ when(parameterContext.getParameter()).thenReturn(delegatorParameter);
+
+ Object resolved = extension.resolveParameter(parameterContext, null);
+
+ assertThat(resolved, sameInstance(delegator));
+ }
+
+ @Test
+ void parallelExecutionStaysDisabledEvenWhenSystemPropertiesRequestIt() {
+ System.setProperty(PARALLEL_ENABLED, "true");
+ System.setProperty(PARALLEL_MODE_DEFAULT, "concurrent");
+ try {
+ Thread callingThread = Thread.currentThread();
+ ThreadRecordingFixture.EXECUTED_ON.clear();
+
+ JupiterTestExtension.JupiterTestSuite suite =
+ new
JupiterTestExtension.JupiterTestSuite(ThreadRecordingFixture.class);
+ suite.run(new TestResult());
+
+ synchronized (ThreadRecordingFixture.EXECUTED_ON) {
+ assertThat(ThreadRecordingFixture.EXECUTED_ON,
hasSize(ThreadRecordingFixture.METHOD_COUNT));
+ assertThat(ThreadRecordingFixture.EXECUTED_ON,
everyItem(sameInstance(callingThread)));
+ }
+ } finally {
+ System.clearProperty(PARALLEL_ENABLED);
+ System.clearProperty(PARALLEL_MODE_DEFAULT);
+ }
+ }
+
+ //ALLOW PUBLIC FIELDS
+ static class CorrectlyNamedFields {
+ Delegator delegator;
+ LocalDispatcher dispatcher;
+ }
+
+ static class MisnamedDelegatorField {
+ Delegator myDelegator;
+ }
+
+ static class NoRelevantFields {
+ String name;
+ }
+
+ //FORBID PUBLIC FIELDS
+
+ // Tagged so build.gradle's `test` task (excludeTags 'jupiterIntegration')
excludes it from
+ // plain gradlew test's classpath-scan discovery entirely - it must not be
independently
+ // discovered and run as its own phantom test class, only constructed and
run directly by
+ // parallelExecutionStaysDisabledEvenWhenSystemPropertiesRequestIt()
above. Do NOT use
+ // @JunitJupiterTest here: that composed annotation also adds
@ExtendWith(JupiterTestExtension),
+ // whose evaluateExecutionCondition() would find
CURRENT_DELEGATOR/CURRENT_DISPATCHER both null
+ // (this fixture is never passed through setDelegator()/setDispatcher())
and disable every
+ // method, making the regression test's hasSize(METHOD_COUNT) assertion
fail.
+ @Tag(JupiterTestExtension.INTEGRATION_TAG)
+ static class ThreadRecordingFixture {
+ static final int METHOD_COUNT = 4;
+ static final List<Thread> EXECUTED_ON =
Collections.synchronizedList(new ArrayList<>());
+
+ @Test
+ void methodA() {
+ EXECUTED_ON.add(Thread.currentThread());
+ }
+
+ @Test
+ void methodB() {
+ EXECUTED_ON.add(Thread.currentThread());
+ }
+
+ @Test
+ void methodC() {
+ EXECUTED_ON.add(Thread.currentThread());
+ }
+
+ @Test
+ void methodD() {
+ EXECUTED_ON.add(Thread.currentThread());
+ }
+ }
+
+ static class ParameterFixtures {
+ void dummy(Delegator delegator) {
+ }
+ }
+}