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 bd0522c6e0 Tag testIntegration log lines with the test case that 
produced them via MDC (Mapped Diagnostic Context) (#1662)
bd0522c6e0 is described below

commit bd0522c6e0c353956259b4ad7680a120ef8e6b3d
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Sun Aug 16 23:15:19 2026 +0530

    Tag testIntegration log lines with the test case that produced them via MDC 
(Mapped Diagnostic Context) (#1662)
    
    testIntegration runs JUnit tests inside the same OFBiz process, writing
    to the same console/log files as normal server operation. A test that 
deliberately
    exercises a failure path (e.g. a service-error test) produces ERROR-level 
log lines
    that are visually indistinguishable from a real bug - nothing in the line 
says it
    was expected.
    
    This sets a testCase field via Log4j2's ThreadContext (MDC) for the
    duration of each test method - JUnit 3 via 
Junit3ResultBridge.startTest/endTest, Jupiter
    via JupiterTestExtension's JupiterClassRunner listener - and references it
    in the console/ofbiz.log/error.log pattern:
    
        ... |%level{length=1}| %notEmpty{[TEST:%X{testCase}] }%message%n
    
    %notEmpty{...} only renders when the key is set, so normal server output
    is unaffected - the tag only appears on lines logged while a test method is
    actually running, including nested framework calls on the same thread
    (GenericDelegator, ServiceDispatcher, TransactionUtil, etc.), since MDC 
rides the thread
    automatically. Example:
    
    ... |ServiceDispatcher :578|E| 
[TEST:GroovyDslServiceEngineTests#testGroovyServices] Error in Service 
[testGroovyPingError]: Service result error
    
    This reuses the same MDC pattern already established by CorrelationValve
    for HTTP request correlation (requestId/visitId/userLoginId) - no new 
logging
    mechanism, no new dependency. The opt-in ECS JSON logging profile picks up 
the new
    field automatically, since it already resolves the whole MDC map 
generically -
    no template change needed.
    
    Also adds a safety net (TestRunContainer.runSuiteEntries's loop and
    JupiterClassRunner's outer finally) so the field can't get stranded on
    the thread if a test's own run(TestResult) override lets an exception 
escape before
    its normal cleanup runs.
    
    I got the idea to use MDC (Mapped Diagnostic Context) for OFBiz from the 
below links:
    - https://logging.apache.org/log4j/2.12.x/manual/thread-context.html
    - https://www.baeldung.com/mdc-in-log4j-2-logback
    
    Verified with `./gradlew "ofbiz --test component=service"`: 
[TEST:Class#method] tags
    appear exactly as designed on 270 log lines, propagate through nested
    framework calls, and normal server bootstrap output (before any test runs) 
carries
    none. Full `./gradlew test` unit suite and the project's 
checkstyle/codenarc checks
    pass.
---
 framework/base/config/log4j2.xml                   |  2 +-
 .../apache/ofbiz/testtools/Junit3ResultBridge.java | 13 +++++-
 .../ofbiz/testtools/JupiterTestExtension.java      | 52 +++++++++++++---------
 .../apache/ofbiz/testtools/TestRunContainer.java   | 30 ++++++++-----
 .../ofbiz/testtools/Junit3ResultBridgeTest.java    | 30 +++++++++++++
 .../ofbiz/testtools/JupiterClassRunnerTest.java    | 20 +++++++++
 .../ofbiz/testtools/TestRunContainerTest.java      | 35 +++++++++++++++
 7 files changed, 148 insertions(+), 34 deletions(-)

diff --git a/framework/base/config/log4j2.xml b/framework/base/config/log4j2.xml
index b114a8f991..64b3524604 100644
--- a/framework/base/config/log4j2.xml
+++ b/framework/base/config/log4j2.xml
@@ -27,7 +27,7 @@ under the License.
         <Property name="lineToken_prod"></Property>
         <Property name="includeLocation_dev">true</Property>
         <Property name="includeLocation_prod">false</Property>
-        <Property name="logPattern">%date{DEFAULT} |%-20.20thread 
|%-30.30logger{1}${lineToken_${sys:ofbiz.env:-dev}}|%level{length=1}| 
%message%n</Property>
+        <Property name="logPattern">%date{DEFAULT} |%-20.20thread 
|%-30.30logger{1}${lineToken_${sys:ofbiz.env:-dev}}|%level{length=1}| 
%notEmpty{[TEST:%X{testCase}] }%message%n</Property>
         <Property 
name="includeLocation">${includeLocation_${sys:ofbiz.env:-dev}}</Property>
         <!--
           Placeholder for the JSON event template used by the opt-in 
structured logging appender
diff --git 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/Junit3ResultBridge.java
 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/Junit3ResultBridge.java
index 069ab94c10..f0e9717577 100644
--- 
a/framework/testtools/src/main/java/org/apache/ofbiz/testtools/Junit3ResultBridge.java
+++ 
b/framework/testtools/src/main/java/org/apache/ofbiz/testtools/Junit3ResultBridge.java
@@ -22,6 +22,7 @@ import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 
+import org.apache.logging.log4j.ThreadContext;
 import org.apache.ofbiz.testtools.SuiteReportSink.Outcome;
 
 import junit.framework.AssertionFailedError;
@@ -54,6 +55,9 @@ final class Junit3ResultBridge implements TestListener {
     private final Map<Test, Long> startTimes = new IdentityHashMap<>();
     private final Map<Test, Outcome> outcomes = new IdentityHashMap<>();
 
+    // Must match the %X{testCase} reference in 
framework/base/config/log4j2.xml's logPattern.
+    private static final String TEST_CASE_MDC_KEY = "testCase";
+
     Junit3ResultBridge(SuiteReportSink... sinks) {
         this.sinks = List.of(sinks);
     }
@@ -61,6 +65,7 @@ final class Junit3ResultBridge implements TestListener {
     @Override
     public void startTest(Test test) {
         startTimes.put(test, System.currentTimeMillis());
+        ThreadContext.put(TEST_CASE_MDC_KEY, test.getClass().getSimpleName() + 
"#" + nameOf(test));
         ReportingSupport.dispatch(sinks, sink -> 
sink.testStarted(classnameOf(test), nameOf(test)));
     }
 
@@ -80,8 +85,12 @@ final class Junit3ResultBridge implements TestListener {
         // JUnit 3's dispatch order is always startTest -> 
[addFailure|addError]* -> endTest, with
         // endTest() called exactly once per test regardless of how many 
addFailure()/addError() calls
         // preceded it - so this is the single dispatch point for 
testFinished().
-        Outcome outcome = outcomes.remove(test);
-        report(test, outcome != null ? outcome : Outcome.passed());
+        try {
+            Outcome outcome = outcomes.remove(test);
+            report(test, outcome != null ? outcome : Outcome.passed());
+        } finally {
+            ThreadContext.remove(TEST_CASE_MDC_KEY);
+        }
     }
 
     private void report(Test test, Outcome outcome) {
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 f9ac196d74..39ccfc814d 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
@@ -25,6 +25,7 @@ import java.util.Map;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import org.apache.logging.log4j.ThreadContext;
 import org.apache.ofbiz.base.util.Debug;
 import org.apache.ofbiz.entity.Delegator;
 import org.apache.ofbiz.service.LocalDispatcher;
@@ -130,6 +131,9 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
     /** Read by build.gradle's `test` task ({@code excludeTags}) and by {@link 
JunitJupiterTest}. */
     public static final String INTEGRATION_TAG = "jupiterIntegration";
 
+    // Must match the %X{testCase} reference in 
framework/base/config/log4j2.xml's logPattern.
+    static final String TEST_CASE_MDC_KEY = "testCase";
+
     static final ThreadLocal<Delegator> CURRENT_DELEGATOR = new 
ThreadLocal<>();
     static final ThreadLocal<LocalDispatcher> CURRENT_DISPATCHER = new 
ThreadLocal<>();
 
@@ -313,6 +317,7 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
                     public void executionStarted(TestIdentifier 
testIdentifier) {
                         if (testIdentifier.isTest()) {
                             startTimes.put(testIdentifier.getUniqueId(), 
System.currentTimeMillis());
+                            ThreadContext.put(TEST_CASE_MDC_KEY, 
testClass.getSimpleName() + "#" + reportingName(testIdentifier));
                             ReportingSupport.dispatch(sinks, sink -> 
sink.testStarted(testClass.getName(), reportingName(testIdentifier)));
                         }
                     }
@@ -331,33 +336,38 @@ public class JupiterTestExtension implements 
ParameterResolver, TestInstancePost
                             reportContainerFailure(testIdentifier, 
testExecutionResult);
                             return;
                         }
-                        String name = reportingName(testIdentifier);
-                        long elapsed = System.currentTimeMillis()
-                                - 
startTimes.getOrDefault(testIdentifier.getUniqueId(), 
System.currentTimeMillis());
-                        if (testExecutionResult.getStatus() == 
TestExecutionResult.Status.ABORTED) {
-                            // A JUnit 5 Assumptions.assumeTrue/assumeFalse 
failure: a deliberate skip,
-                            // not a defect - logged, not reported as a 
failure/error, the same way a
-                            // @Disabled test is reported via 
executionSkipped() above, except that
-                            // testStarted()/testFinished() must still fire 
here since the test already
-                            // started (see SuiteReportSink.Outcome's javadoc 
for why this reports Passed).
-                            
testExecutionResult.getThrowable().ifPresent(throwable ->
-                                    Debug.logInfo("[JUNIT] ABORTED: " + 
testIdentifier.getDisplayName()
-                                            + " (" + testClass.getName() + ") 
- " + throwable.getMessage(), MODULE));
-                            ReportingSupport.dispatch(sinks, sink -> 
sink.testFinished(testClass.getName(), name, elapsed, Outcome.passed()));
-                            return;
+                        try {
+                            String name = reportingName(testIdentifier);
+                            long elapsed = System.currentTimeMillis()
+                                    - 
startTimes.getOrDefault(testIdentifier.getUniqueId(), 
System.currentTimeMillis());
+                            if (testExecutionResult.getStatus() == 
TestExecutionResult.Status.ABORTED) {
+                                // A JUnit 5 
Assumptions.assumeTrue/assumeFalse failure: a deliberate skip,
+                                // not a defect - logged, not reported as a 
failure/error, the same way a
+                                // @Disabled test is reported via 
executionSkipped() above, except that
+                                // testStarted()/testFinished() must still 
fire here since the test already
+                                // started (see SuiteReportSink.Outcome's 
javadoc for why this reports Passed).
+                                
testExecutionResult.getThrowable().ifPresent(throwable ->
+                                        Debug.logInfo("[JUNIT] ABORTED: " + 
testIdentifier.getDisplayName()
+                                                + " (" + testClass.getName() + 
") - " + throwable.getMessage(), MODULE));
+                                ReportingSupport.dispatch(sinks, sink -> 
sink.testFinished(testClass.getName(), name, elapsed, Outcome.passed()));
+                                return;
+                            }
+                            Outcome outcome = 
testExecutionResult.getThrowable()
+                                    .map(throwable -> throwable instanceof 
AssertionError
+                                            ? 
Outcome.failure(throwable.getMessage(), throwable.getClass().getName(),
+                                                    
ReportingSupport.stackTraceOf(throwable))
+                                            : Outcome.error(throwable))
+                                    .orElseGet(Outcome::passed);
+                            ReportingSupport.dispatch(sinks, sink -> 
sink.testFinished(testClass.getName(), name, elapsed, outcome));
+                        } finally {
+                            ThreadContext.remove(TEST_CASE_MDC_KEY);
                         }
-                        Outcome outcome = testExecutionResult.getThrowable()
-                                .map(throwable -> throwable instanceof 
AssertionError
-                                        ? 
Outcome.failure(throwable.getMessage(), throwable.getClass().getName(),
-                                                
ReportingSupport.stackTraceOf(throwable))
-                                        : Outcome.error(throwable))
-                                .orElseGet(Outcome::passed);
-                        ReportingSupport.dispatch(sinks, sink -> 
sink.testFinished(testClass.getName(), name, elapsed, outcome));
                     }
                 });
             } catch (Throwable t) {
                 reportClassExecutionFailure(t);
             } finally {
+                ThreadContext.remove(TEST_CASE_MDC_KEY);
                 JupiterTestExtension.CURRENT_DELEGATOR.remove();
                 JupiterTestExtension.CURRENT_DISPATCHER.remove();
             }
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 05a4ad2c9f..055daf4af0 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
@@ -24,6 +24,7 @@ import java.io.FileOutputStream;
 import java.util.List;
 import java.util.Map;
 
+import org.apache.logging.log4j.ThreadContext;
 import org.apache.ofbiz.base.container.Container;
 import org.apache.ofbiz.base.container.ContainerException;
 import org.apache.ofbiz.base.start.StartupCommand;
@@ -128,16 +129,25 @@ public class TestRunContainer implements Container {
         TestResult junit3Result = new TestResult();
         junit3Result.addListener(new Junit3ResultBridge(sinks));
         for (SuiteEntry entry : entries) {
-            if (entry instanceof Junit3Entry junit3Entry) {
-                junit3Entry.test().run(junit3Result);
-            } else if (entry instanceof JupiterEntry jupiterEntry) {
-                new 
JupiterTestExtension.JupiterClassRunner(jupiterEntry.testClass(), delegator, 
dispatcher, sinks).run();
-            } else {
-                // SuiteEntry is sealed permits Junit3Entry, JupiterEntry, so 
this is unreachable today -
-                // but Java 17 doesn't support exhaustive switch over sealed 
types without preview
-                // features, so this explicit throw is the substitute: a 
future third variant fails loudly
-                // here instead of being silently skipped.
-                throw new IllegalStateException("Unknown SuiteEntry type: " + 
entry.getClass());
+            try {
+                if (entry instanceof Junit3Entry junit3Entry) {
+                    junit3Entry.test().run(junit3Result);
+                } else if (entry instanceof JupiterEntry jupiterEntry) {
+                    new 
JupiterTestExtension.JupiterClassRunner(jupiterEntry.testClass(), delegator, 
dispatcher, sinks).run();
+                } else {
+                    // SuiteEntry is sealed permits Junit3Entry, JupiterEntry, 
so this is unreachable today -
+                    // but Java 17 doesn't support exhaustive switch over 
sealed types without preview
+                    // features, so this explicit throw is the substitute: a 
future third variant fails loudly
+                    // here instead of being silently skipped.
+                    throw new IllegalStateException("Unknown SuiteEntry type: 
" + entry.getClass());
+                }
+            } finally {
+                // Net for JUnit 3 test engines 
(ServiceTest/SimpleMethodTest/EntityXmlAssertTest) whose
+                // own run(TestResult) overrides can let an unchecked 
exception escape before reaching
+                // Junit3ResultBridge.endTest() - without this, testCase would 
stay armed on this thread
+                // for every subsequent log line until the next test 
overwrites it. Also correct (a no-op
+                // clearing an already-cleared key) for the two paths that 
already clear it themselves.
+                ThreadContext.remove(JupiterTestExtension.TEST_CASE_MDC_KEY);
             }
         }
     }
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/Junit3ResultBridgeTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/Junit3ResultBridgeTest.java
index c70dcd624b..bbcb15d833 100644
--- 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/Junit3ResultBridgeTest.java
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/Junit3ResultBridgeTest.java
@@ -19,6 +19,7 @@
 package org.apache.ofbiz.testtools;
 
 import org.junit.jupiter.api.Test;
+import org.apache.logging.log4j.ThreadContext;
 
 import junit.framework.AssertionFailedError;
 import junit.framework.TestCase;
@@ -28,6 +29,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
 
 class Junit3ResultBridgeTest {
 
@@ -82,6 +84,18 @@ class Junit3ResultBridgeTest {
         assertThat(failure.message(), is("first failure"));
     }
 
+    @Test
+    void tagsLogContextWithTestCaseDuringExecutionAndClearsItAfter() {
+        RecordingSink sink = new RecordingSink();
+        TestResult result = new TestResult();
+        result.addListener(new Junit3ResultBridge(sink));
+
+        new MdcCapturingCase().run(result);
+
+        assertThat(MdcCapturingCase.capturedTestCase(), 
is("MdcCapturingCase#testCaptureMdc"));
+        assertThat(ThreadContext.get("testCase"), nullValue());
+    }
+
     /**
      * Mirrors how ServiceTest/SimpleMethodTest/EntityXmlAssertTest override 
run(TestResult) directly -
      * calling result.startTest(this), then multiple result.addFailure(this, 
...) calls for one logical
@@ -103,6 +117,22 @@ class Junit3ResultBridgeTest {
         }
     }
 
+    public static class MdcCapturingCase extends TestCase {
+        private static String capturedTestCase;
+
+        MdcCapturingCase() {
+            super("testCaptureMdc");
+        }
+
+        public void testCaptureMdc() {
+            capturedTestCase = ThreadContext.get("testCase");
+        }
+
+        static String capturedTestCase() {
+            return capturedTestCase;
+        }
+    }
+
     public static class PassingCase extends TestCase {
         PassingCase() {
             super("testPass");
diff --git 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterClassRunnerTest.java
 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterClassRunnerTest.java
index b21e796a10..5c0636832c 100644
--- 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterClassRunnerTest.java
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/JupiterClassRunnerTest.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
+import org.apache.logging.log4j.ThreadContext;
 import org.apache.ofbiz.entity.Delegator;
 import org.apache.ofbiz.service.LocalDispatcher;
 import org.junit.jupiter.api.AfterEach;
@@ -143,6 +144,15 @@ class JupiterClassRunnerTest {
         assertThat(JupiterTestExtension.CURRENT_DISPATCHER.get(), nullValue());
     }
 
+    @Test
+    void tagsLogContextWithTestCaseDuringExecutionAndClearsItAfter() {
+        new JupiterTestExtension.JupiterClassRunner(MdcCapturingFixture.class, 
mock(Delegator.class),
+                mock(LocalDispatcher.class), new RecordingSink()).run();
+
+        assertThat(MdcCapturingFixture.capturedTestCase, 
is("MdcCapturingFixture#capturesMdc"));
+        assertThat(ThreadContext.get("testCase"), nullValue());
+    }
+
     @Test
     void reportsRealClassAndMethodNamesNotASharedSyntheticClass() {
         RecordingSink sink = new RecordingSink();
@@ -231,6 +241,16 @@ class JupiterClassRunnerTest {
         }
     }
 
+    @Tag(JupiterTestExtension.INTEGRATION_TAG)
+    static class MdcCapturingFixture {
+        static String capturedTestCase;
+
+        @Test
+        void capturesMdc() {
+            capturedTestCase = ThreadContext.get("testCase");
+        }
+    }
+
     @Tag(JupiterTestExtension.INTEGRATION_TAG)
     @ExtendWith(JupiterTestExtension.class)
     static class AssumptionFixture {
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
index 7f052604ad..c547a656be 100644
--- 
a/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
+++ 
b/framework/testtools/src/test/java/org/apache/ofbiz/testtools/TestRunContainerTest.java
@@ -20,16 +20,20 @@ package org.apache.ofbiz.testtools;
 
 import java.util.List;
 
+import org.apache.logging.log4j.ThreadContext;
 import org.apache.ofbiz.entity.Delegator;
 import org.apache.ofbiz.service.LocalDispatcher;
 import org.junit.jupiter.api.Test;
 
 import junit.framework.TestCase;
+import junit.framework.TestResult;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -96,6 +100,17 @@ class TestRunContainerTest {
         assertThat(sink.testFinishedCalls.get(0).outcome(), 
instanceOf(SuiteReportSink.Outcome.Failure.class));
     }
 
+    @Test
+    void 
runSuiteEntriesClearsTheTestCaseMdcFieldEvenWhenAJunit3EngineEscapesBeforeEndTest()
 {
+        RecordingSink sink = new RecordingSink();
+        List<SuiteEntry> entries = List.of(new SuiteEntry.Junit3Entry(new 
StartsThenThrowsBeforeEndTestCase()));
+
+        assertThrows(RuntimeException.class, () ->
+                TestRunContainer.runSuiteEntries(entries, 
mock(Delegator.class), mock(LocalDispatcher.class), sink));
+
+        assertThat(ThreadContext.get(JupiterTestExtension.TEST_CASE_MDC_KEY), 
nullValue());
+    }
+
     static class NamedCase extends TestCase {
         NamedCase(String name) {
             super(name);
@@ -123,4 +138,24 @@ class TestRunContainerTest {
         void onlyTest() {
         }
     }
+
+    /**
+     * Mirrors how ServiceTest/SimpleMethodTest/EntityXmlAssertTest override 
run(TestResult) directly -
+     * calling result.startTest(this) (which arms the testCase MDC field via 
Junit3ResultBridge.startTest())
+     * themselves, then running the test's own logic before reaching 
result.endTest(this). An unchecked
+     * exception thrown from that logic - as opposed to the 
addFailure()/addError() calls those engines
+     * normally make - propagates straight out of run(TestResult), skipping 
endTest() (and therefore
+     * Junit3ResultBridge.endTest()'s ThreadContext.remove()) entirely.
+     */
+    static class StartsThenThrowsBeforeEndTestCase extends TestCase {
+        StartsThenThrowsBeforeEndTestCase() {
+            super("startsThenThrows");
+        }
+
+        @Override
+        public void run(TestResult result) {
+            result.startTest(this);
+            throw new RuntimeException("escaped before endTest()");
+        }
+    }
 }

Reply via email to