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) {
+        }
+    }
+}

Reply via email to