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 0f6b6b2b46 Harden the Jupiter test bridge and catch two classes of 
silent test-infra failures (#1571)
0f6b6b2b46 is described below

commit 0f6b6b2b46c6ba05196693c032fac210a209e631
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Thu Aug 6 11:01:14 2026 +0530

    Harden the Jupiter test bridge and catch two classes of silent test-infra 
failures (#1571)
    
    Harden the Jupiter test bridge and catch two classes of silent
    test-infra failures
    
    Fixes a bug where a throwing @BeforeAll or an exception escaping
    Jupiter's launcher could silently drop or abort test suites in 
testIntegration
    with no build failure. Also adds two build-time checks (testdef class-name 
typos
    and bare @ExtendWith(JupiterTestExtension.class) usage), fixes a stale
    JUnit-3 doc example, and adds checkstyleTest to the pre-push hook.
    
    Verified: full unit suite (580 tests), checkstyle/codenarc, the two new
    verification tasks, and the full integration suite all pass.
---
 build.gradle                                       | 106 ++++++++++++++++++++-
 .../testtools/src/docs/asciidoc/unit-tests.adoc    |  43 ++++++++-
 .../ofbiz/testtools/JupiterTestExtension.java      |  26 +++++
 .../apache/ofbiz/testtools/TestRunContainer.java   |  38 +++++++-
 .../testtools/JupiterInjectionGuardsTest.java      |  41 ++++++++
 .../ofbiz/testtools/TestRunContainerTest.java      |  67 +++++++++++++
 6 files changed, 317 insertions(+), 4 deletions(-)

diff --git a/build.gradle b/build.gradle
index 07b8f1261c..9ac503e168 100644
--- a/build.gradle
+++ b/build.gradle
@@ -341,7 +341,7 @@ gitHooks {
     // where "$rootDir/.git" (the plugin's default) is a gitlink file, not a 
directory.
     def gitCommonDir = "git -C ${rootDir} rev-parse 
--git-common-dir".execute().text.trim()
     hooksDirectory.set(new File(rootDir, 
gitCommonDir).toPath().resolve('hooks').toFile())
-    hooks = ['pre-push': 'checkstyleMain codenarcMain codenarcTest']
+    hooks = ['pre-push': 'checkstyleMain checkstyleTest codenarcMain 
codenarcTest']
 }
 
 // Checks OFBiz Groovy coding conventions.
@@ -396,6 +396,110 @@ test {
     }
 }
 
+// 'gradlew test --tests <ClassName>' against a @JunitJupiterTest class fails 
with Gradle's generic
+// "No tests found for given includes" rather than pointing at 
testIntegration: the excludeTags
+// filter above excludes the class from discovery entirely before --tests 
filtering ever runs, so
+// from Gradle's perspective the class simply doesn't exist. This doesn't 
change that failure - it's
+// otherwise a genuinely useful safety net for a real --tests typo - it just 
appends a pointer to the
+// right command for the specific case where the cause is a Jupiter 
integration-test class.
+gradle.taskGraph.afterTask { Task task, TaskState state ->
+    if (task.path == ':test' && state.failure) {
+        Throwable rootCause = state.failure
+        while (rootCause.cause != null) {
+            rootCause = rootCause.cause
+        }
+        if (rootCause.message?.contains('No tests found for given includes')) {
+            logger.error('')
+            logger.error('[hint] If the class above is annotated 
@JunitJupiterTest, this failure is expected: '
+                    + 'that tag excludes it from \'gradlew test\' discovery 
entirely (see JunitJupiterTest\'s '
+                    + 'javadoc), so --tests never gets a chance to filter it 
in. Run it via '
+                    + '\'gradlew testIntegration\' or \'ofbiz --test\' 
instead.')
+            logger.error('')
+        }
+    }
+}
+
+// ModelTestSuite.parseTestElement() resolves every 
<junit-test-suite>/<jupiter-test-suite>
+// class-name attribute at runtime via ObjectType.loadClass(), but a lookup 
failure there is
+// only Debug.logError()'d - the whole suite is silently dropped from 
testIntegration with no
+// build failure and no signal short of reading the log output. This task 
repeats that same
+// lookup at build time, against the compiled test classpath, so a typo'd or 
stale (post-rename)
+// class-name fails the build instead of quietly vanishing from the test run.
+task verifyTestdefClassNames(group: 'Verification', dependsOn: testClasses) {
+    description = 'Fails the build if any testdef XML class-name attribute 
cannot be resolved on the classpath'
+    def testdefDirs = getDirectoryInActiveComponentsIfExists('testdef')
+    def testdefXmlFiles = files(testdefDirs.collect { fileTree(it) { include 
'**/*.xml' } })
+    def testRuntimeClasspath = sourceSets.test.runtimeClasspath
+    inputs.files(testdefXmlFiles)
+    inputs.files(testRuntimeClasspath)
+    doLast {
+        URL[] classpathUrls = testRuntimeClasspath.files.collect { 
it.toURI().toURL() }
+        URLClassLoader classpathLoader = new URLClassLoader(classpathUrls, 
getClass().classLoader)
+        List<String> unresolved = []
+        testdefXmlFiles.each { xmlFile ->
+            def root = new XmlParser(false, false).parse(xmlFile)
+            root.depthFirst()
+                .findAll { it.name() == 'junit-test-suite' || it.name() == 
'jupiter-test-suite' }
+                .each { node ->
+                    String className = node.'@class-name'
+                    if (!className) {
+                        return
+                    }
+                    try {
+                        Class.forName(className, false, classpathLoader)
+                    } catch (Throwable t) {
+                        unresolved << "${xmlFile}: <${node.name()} 
class-name=\"${className}\"/> - ${t}"
+                    }
+                }
+        }
+        if (!unresolved.isEmpty()) {
+            throw new GradleException('The following testdef class-name 
references cannot be resolved on the '
+                    + 'classpath (typo, or the class was renamed/moved/deleted 
without updating the testdef XML):\n'
+                    + unresolved.collect { " - ${it}" }.join('\n'))
+        }
+    }
+}
+check.dependsOn verifyTestdefClassNames
+
+// JunitJupiterTest's own javadoc documents that bare 
@ExtendWith(JupiterTestExtension.class)
+// skips gradlew test's excludeTags-based exclusion - it's only caught at 
runtime by
+// evaluateExecutionCondition(), reporting a skip instead of being excluded 
from discovery
+// outright. That's an intentional, documented escape hatch for 
JupiterTestExtension's own home
+// (JunitJupiterTest's definition and JupiterInjectionGuardsTest's fixtures, 
both under
+// framework/testtools, use it deliberately to define/exercise the escape 
hatch itself), but
+// nothing stops a future contributor from using the bare form on a real test 
class by habit or
+// copy-paste. This cheap grep-based check catches that everywhere else.
+task verifyNoBareJupiterExtendWith(group: 'Verification') {
+    description = 'Fails the build if a bare 
@ExtendWith(JupiterTestExtension.class) is used ' +
+            'outside framework/testtools instead of @JunitJupiterTest'
+    def testInfraDir = file('framework/testtools')
+    def sourceFiles = files(
+        (getDirectoryInActiveComponentsIfExists('src/main/java')
+            + getDirectoryInActiveComponentsIfExists('src/main/groovy')
+            + getDirectoryInActiveComponentsIfExists('src/test/java')
+            + getDirectoryInActiveComponentsIfExists('src/test/groovy'))
+            .collect { dir -> fileTree(dir) { include '**/*.java', 
'**/*.groovy' } }
+    ).filter { file -> !file.toPath().startsWith(testInfraDir.toPath()) }
+    inputs.files(sourceFiles)
+    doLast {
+        def bareExtendWith = 
~/@ExtendWith\(\s*(org\.apache\.ofbiz\.testtools\.)?JupiterTestExtension(\.class)?\s*\)/
+        List<String> offenders = []
+        sourceFiles.each { file ->
+            file.readLines().eachWithIndex { line, idx ->
+                if (bareExtendWith.matcher(line).find()) {
+                    offenders << "${file}:${idx + 1}: ${line.trim()}"
+                }
+            }
+        }
+        if (!offenders.isEmpty()) {
+            throw new GradleException('Bare 
@ExtendWith(JupiterTestExtension.class) found outside '
+                    + 'framework/testtools - use @JunitJupiterTest instead, so 
gradlew test\'s '
+                    + 'excludeTags-based exclusion actually applies (see 
JunitJupiterTest\'s javadoc):\n'
+                    + offenders.collect { " - ${it}" }.join('\n'))
+        }
+    }
+}
+check.dependsOn verifyNoBareJupiterExtendWith
 
 /* ========================================================
  * Tasks
diff --git a/framework/testtools/src/docs/asciidoc/unit-tests.adoc 
b/framework/testtools/src/docs/asciidoc/unit-tests.adoc
index 6cc2b5653d..4838bc80e7 100644
--- a/framework/testtools/src/docs/asciidoc/unit-tests.adoc
+++ b/framework/testtools/src/docs/asciidoc/unit-tests.adoc
@@ -63,14 +63,53 @@ respectively like this:
     </test-case>
 ----
 
-=== JUnit
+=== JUnit 3 (legacy)
 Specific class's name which will be tested, in a class-name attribute like 
this:
 [source, xml]
+----
+    <test-case case-name="example-tests">
+        <junit-test-suite 
class-name="org.apache.ofbiz.example.test.ExampleTests"/>
+    </test-case>
+----
+This style predates the JUnit 5 (Jupiter) migration and is being phased out - 
new test classes
+should use `<jupiter-test-suite>` instead, described next.
+
+=== Jupiter
+JUnit 5 (Jupiter) test classes are wired the same way, through a 
`<jupiter-test-suite>` element
+instead of `<junit-test-suite>`:
+[source, xml]
 ----
     <test-case case-name="service-tests">
-        <junit-test-suite 
class-name="org.apache.ofbiz.service.test.ServiceEngineTests"/>
+        <jupiter-test-suite 
class-name="org.apache.ofbiz.service.test.ServiceEngineTests"/>
     </test-case>
 ----
+The Java/Groovy class itself needs matching Jupiter annotations - 
`@JunitJupiterTest` on the
+class, `@Test` on each test method:
+[source, java]
+----
+    @JunitJupiterTest
+    public class ServiceEngineTests implements JupiterTestHelper {
+        @Test
+        @Order(1)
+        void testXxx() {
+            ...
+        }
+    }
+----
+Two things worth knowing before adding a new Jupiter test class:
+
+* *Both halves are required, and a mismatch fails silently.* The `class-name` 
attribute above
+  and the class's own annotations do two separate jobs - the testdef XML 
registers the class
+  with `ofbiz --test`/`testIntegration`, the annotations make it a valid 
Jupiter test. A typo'd
+  or stale `class-name` (e.g. after renaming the class) is only logged as an 
error, not a build
+  failure - the suite is quietly dropped from the run with no other signal. 
Double-check the
+  class name after any rename.
+* *Method execution order defaults to source declaration order, not enforced 
unless you say so.*
+  Add an explicit `@Order(n)` to every `@Test` method if any of them depend on 
state a previous
+  method left behind - a common pattern in integration tests that share one 
suite-level
+  Delegator/LocalDispatcher. Without `@Order`, a class is relying on 
declaration order only
+  implicitly - reordering methods, adding one in the middle, or splitting the 
file can silently
+  break it later.
 
 === Service
 Specific service's name which will be tested in a service-name attribute like 
this:
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
index b881cc4987..0cce71dc60 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/JupiterTestExtension.java
@@ -365,6 +365,7 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
                     @Override
                     public void executionFinished(TestIdentifier 
testIdentifier, TestExecutionResult testExecutionResult) {
                         if (!testIdentifier.isTest()) {
+                            reportContainerFailure(testIdentifier, 
testExecutionResult, result);
                             return;
                         }
                         Test leaf = 
leafTests.get(testIdentifier.getUniqueId());
@@ -395,6 +396,31 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
             }
         }
 
+        /**
+         * Without this, a container-level failure - a static {@literal 
@}BeforeAll (or any other
+         * class-level setup JUnit 5 runs before its children) throwing - is 
silently discarded:
+         * executionFinished() above returns before doing anything for a 
non-test identifier, so the
+         * FAILED/ABORTED result JUnit 5 reports once, on the container, never 
reaches
+         * result.addError()/addFailure(). That would leave every {@literal 
@}Test method in the class
+         * never individually started, results.wasSuccessful() still true, and 
testIntegration reporting
+         * full success for a class whose tests never actually ran. Reported 
as a synthetic leaf (mirroring
+         * JupiterLeafTest's own "reporting handle only" pattern below) since 
TestResult has no native
+         * concept of a class-level failure with no associated test case.
+         */
+        private void reportContainerFailure(TestIdentifier testIdentifier, 
TestExecutionResult testExecutionResult, TestResult result) {
+            TestExecutionResult.Status status = 
testExecutionResult.getStatus();
+            if (status != TestExecutionResult.Status.FAILED && status != 
TestExecutionResult.Status.ABORTED) {
+                return;
+            }
+            Test leaf = new JupiterLeafTest(testClass.getSimpleName() + 
".initializationError", testClass.getName());
+            Throwable throwable = testExecutionResult.getThrowable()
+                    .orElseGet(() -> new AssertionError("Container '" + 
testIdentifier.getDisplayName()
+                            + "' reported " + status + " with no throwable"));
+            result.startTest(leaf);
+            result.addError(leaf, throwable);
+            result.endTest(leaf);
+        }
+
         /**
          * getLegacyReportingName() reports plain @Test methods as 
"methodName(ParamType1, ParamType2)"
          * and @ParameterizedTest invocations as "methodName(ParamType1, 
ParamType2)[index]" - the
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunContainer.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunContainer.java
index 6f097d1857..6891fff421 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunContainer.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/TestRunContainer.java
@@ -87,7 +87,19 @@ public class TestRunContainer implements Container {
 
             // test
             xml.startTestSuite(test);
-            suite.run(results);
+            try {
+                suite.run(results);
+            } catch (Throwable t) {
+                // An individual test's exception is always caught by 
TestCase.runBare()/TestResult's
+                // own protected-invocation machinery and reported as that one 
test's error - it can
+                // never take down sibling suites. JupiterTestSuite.run() (the 
JUnit 3 bridge for
+                // Jupiter classes) doesn't have that same guarantee: anything 
escaping its
+                // launcher.execute() call - a JUnitException from a 
discovery/engine-registration problem,
+                // a PreconditionViolationException, or a bug in its own 
TestExecutionListener callback code
+                // - propagates straight out of suite.run() here. Without this 
catch, that would abort every
+                // remaining testdef suite in this loop, not just the one that 
hit the problem.
+                reportSuiteExecutionFailure(suite, results, t);
+            }
             test.setCounts(results.runCount(), results.failureCount(), 
results.errorCount());
             modelSuite.getDelegator().rollback(); // rollback all entity 
operations
             xml.endTestSuite(test);
@@ -123,6 +135,30 @@ public class TestRunContainer implements Container {
         }
     }
 
+    /**
+     * Reports an exception that escaped suite.run() itself as a synthetic 
suite-level error instead of
+     * letting it propagate out of start()'s for loop - see the try/catch 
around suite.run() above for
+     * why that would otherwise abort every remaining testdef suite in the 
run, not just this one.
+     * Reported through the same TestResult/listener pipeline (JunitListener, 
the XML formatter) a normal
+     * addError() would use, so it shows up in the suite's report and 
results.wasSuccessful() correctly
+     * flips to false, rather than this suite silently contributing zero tests 
to the run.
+     *
+     * <p>Package-private rather than private so TestRunContainerTest can 
exercise it directly without
+     * needing a full ofbiz --test container bootstrap.
+     */
+    static void reportSuiteExecutionFailure(TestSuite suite, TestResult 
results, Throwable throwable) {
+        Debug.logError(throwable, "[JUNIT] Suite '" + suite.getName() + "' 
failed to execute: " + throwable, MODULE);
+        Test failureMarker = new TestCase(suite.getName() + 
".suiteExecutionError") {
+            @Override
+            public void run(TestResult result) {
+                throw new UnsupportedOperationException("reporting handle 
only, cannot be run directly");
+            }
+        };
+        results.startTest(failureMarker);
+        results.addError(failureMarker, throwable);
+        results.endTest(failureMarker);
+    }
+
     private static JunitSuiteWrapper prepareJunitSuiteWrapper(Map<String, 
String> testProps) throws ContainerException {
         String component = testProps.get("component");
         String suiteName = testProps.get("suitename");
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
index 4d7b013f1c..ef03033099 100644
--- 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterInjectionGuardsTest.java
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterInjectionGuardsTest.java
@@ -188,6 +188,31 @@ class JupiterInjectionGuardsTest {
         assertThat(suite.countTestCases(), is(1));
     }
 
+    @Test
+    void throwingBeforeAllIsReportedAsAnErrorInsteadOfSilentlyDiscarded() {
+        Delegator delegator = mock(Delegator.class);
+        LocalDispatcher dispatcher = mock(LocalDispatcher.class);
+
+        JupiterTestExtension.JupiterTestSuite suite =
+                new 
JupiterTestExtension.JupiterTestSuite(ThrowingBeforeAllFixture.class);
+        suite.setDelegator(delegator);
+        suite.setDispatcher(dispatcher);
+        TestResult result = new TestResult();
+        suite.run(result);
+
+        // The @Test method itself never starts - JUnit 5 reports the failure 
once, on the class
+        // container, with no [test-method:...] identifier at all (confirmed 
by instrumenting the
+        // listener directly) - so this runCount()/errorCount() pair comes 
entirely from the
+        // synthetic leaf reportContainerFailure() reports, not from 
triggersBeforeAll() itself.
+        // Without that reporting, this would be results.wasSuccessful() == 
true for a class whose
+        // tests never actually ran - the exact false-positive "all green" 
this fix prevents.
+        assertThat(result.runCount(), is(1));
+        assertThat(result.errorCount(), is(1));
+        assertThat(result.wasSuccessful(), is(false));
+        Throwable reported = result.errors().nextElement().thrownException();
+        assertThat(reported.getMessage(), containsString("boom"));
+    }
+
     @Test
     void beforeAllStaticMethodReceivesDelegatorViaParameterResolution() {
         Delegator delegator = mock(Delegator.class);
@@ -303,4 +328,20 @@ class JupiterInjectionGuardsTest {
             // No-op: exists only so the class has a @Test method for 
@BeforeAll to run ahead of.
         }
     }
+
+    // Exercises reportContainerFailure(): a throwing @BeforeAll means JUnit 5 
reports FAILED once, on
+    // the class container, and never starts triggersBeforeAll() below at all.
+    @Tag(JupiterTestExtension.INTEGRATION_TAG)
+    @ExtendWith(JupiterTestExtension.class)
+    static class ThrowingBeforeAllFixture {
+        @BeforeAll
+        static void explode() {
+            throw new IllegalStateException("boom");
+        }
+
+        @Test
+        void triggersBeforeAll() {
+            // No-op: never actually reached - exists only so the class has a 
@Test method.
+        }
+    }
 }
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
new file mode 100644
index 0000000000..78c7e2f204
--- /dev/null
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
@@ -0,0 +1,67 @@
+/*******************************************************************************
+ * 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 org.junit.jupiter.api.Test;
+
+import junit.framework.TestResult;
+import junit.framework.TestSuite;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+
+/**
+ * Exercises reportSuiteExecutionFailure() directly rather than through 
start()'s for loop, which
+ * needs a full ofbiz --test container bootstrap (StartupCommand, a real 
ModelTestSuite/Delegator,
+ * ...) to construct. The loop's own try/catch wiring around 
suite.run(results) is a single,
+ * low-risk control-flow change; this test covers the part that actually has 
behavior worth
+ * verifying - that an escaped exception is turned into a proper suite-level 
error report instead
+ * of a silently-empty, still-"successful" TestResult.
+ */
+class TestRunContainerTest {
+
+    @Test
+    void escapedExceptionIsReportedAsASuiteLevelError() {
+        TestSuite suite = new TestSuite("myFakeSuite");
+        TestResult results = new TestResult();
+        RuntimeException thrown = new RuntimeException("boom");
+
+        TestRunContainer.reportSuiteExecutionFailure(suite, results, thrown);
+
+        // Without this reporting, an exception escaping suite.run() itself (a 
JUnitException from
+        // Jupiter's launcher.execute(), for example) would leave `results` 
with zero tests recorded
+        // and wasSuccessful() still true - the same false-positive "all 
green" class of bug as a
+        // silently-discarded container failure.
+        assertThat(results.runCount(), is(1));
+        assertThat(results.errorCount(), is(1));
+        assertThat(results.wasSuccessful(), is(false));
+        assertThat(results.errors().nextElement().thrownException(), 
is(thrown));
+    }
+
+    @Test
+    void syntheticFailureMarkerIsNamedAfterTheSuite() {
+        TestSuite suite = new TestSuite("myFakeSuite");
+        TestResult results = new TestResult();
+
+        TestRunContainer.reportSuiteExecutionFailure(suite, results, new 
RuntimeException("boom"));
+
+        assertThat(results.errors().nextElement().failedTest().toString(), 
containsString("myFakeSuite.suiteExecutionError"));
+    }
+}

Reply via email to