You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by pk...@apache.org on 2022/07/24 18:54:37 UTC

[logging-log4j2] 01/04: Add `StatusMessages` and `TestProperties` extension

This is an automated email from the ASF dual-hosted git repository.

pkarwasz pushed a commit to branch parallel-tests
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git

commit 3a249203ba88962b53837dd64968e5e3ae739536
Author: Piotr P. Karwasz <pi...@karwasz.org>
AuthorDate: Fri Jun 24 21:12:01 2022 +0200

    Add `StatusMessages` and `TestProperties` extension
    
    Add a JUnit 5 extension, which:
    
    * capture per-test status logger messages and print them to the console
    only if the test fails,
    * set properties that apply only to the current thread.
---
 log4j-api-test/pom.xml                             |   1 +
 ...herSessionListener.java => StatusMessages.java} |  27 ++--
 ...herSessionListener.java => TestProperties.java} |  27 ++--
 .../log4j/test/junit/ExtensionContextAnchor.java   |   8 +-
 .../test/junit/Log4j2LauncherSessionListener.java  |  33 ++++-
 .../logging/log4j/test/junit/SetTestProperty.java  |  59 +++++++++
 .../log4j/test/junit/StatusLoggerExtension.java    | 134 ++++++++++++++++++++
 .../log4j/test/junit/TestPropertyResolver.java     |  75 +++++++++++
 .../log4j/test/junit/TestPropertySource.java       | 141 +++++++++++++++++++++
 ...SessionListener.java => UsingStatusLogger.java} |  33 +++--
 ...ssionListener.java => UsingTestProperties.java} |  36 ++++--
 .../org.junit.jupiter.api.extension.Extension      |   4 +-
 12 files changed, 522 insertions(+), 56 deletions(-)

diff --git a/log4j-api-test/pom.xml b/log4j-api-test/pom.xml
index 698af4e3bc..de7d3667a6 100644
--- a/log4j-api-test/pom.xml
+++ b/log4j-api-test/pom.xml
@@ -147,6 +147,7 @@
             <!-- Enables the `ExtensionContextAnchor` on each test -->
             <junit.jupiter.extensions.autodetection.enabled>true</junit.jupiter.extensions.autodetection.enabled>
             <junit.jupiter.testclass.order.default>org.junit.jupiter.api.ClassOrderer$Random</junit.jupiter.testclass.order.default>
+            <log4j2.junit.disableConsoleStatusListener>true</log4j2.junit.disableConsoleStatusListener>
           </systemPropertyVariables>
         </configuration>
       </plugin>
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/StatusMessages.java
similarity index 58%
copy from log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
copy to log4j-api-test/src/main/java/org/apache/logging/log4j/test/StatusMessages.java
index 51abd82afe..e59a10ce8e 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/StatusMessages.java
@@ -14,22 +14,23 @@
  * See the license for the specific language governing permissions and
  * limitations under the license.
  */
-package org.apache.logging.log4j.test.junit;
 
-import org.apache.logging.log4j.util.PropertiesUtil;
-import org.junit.platform.launcher.LauncherSession;
-import org.junit.platform.launcher.LauncherSessionListener;
+package org.apache.logging.log4j.test;
 
-/**
- * Global Log4j2 test setup.
- */
-public class Log4j2LauncherSessionListener implements LauncherSessionListener {
+import java.util.stream.Stream;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.status.StatusData;
+
+public interface StatusMessages {
 
-    @Override
-    public void launcherSessionOpened(LauncherSession session) {
-        // Prevents `PropertiesUtil` from initializing (and caching the results)
-        // in the middle of a test.
-        PropertiesUtil.getProperties();
+    Stream<StatusData> getMessages();
+
+    default Stream<StatusData> findStatusMessages(Level level) {
+        return getMessages().filter(data -> level.isLessSpecificThan(data.getLevel()));
     }
 
+    default Stream<StatusData> findStatusMessages(Level level, String regex) {
+        return findStatusMessages(level).filter(data -> data.getMessage().getFormattedMessage().matches(regex));
+    }
 }
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/TestProperties.java
similarity index 59%
copy from log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
copy to log4j-api-test/src/main/java/org/apache/logging/log4j/test/TestProperties.java
index 51abd82afe..0c66fc8b94 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/TestProperties.java
@@ -14,22 +14,27 @@
  * See the license for the specific language governing permissions and
  * limitations under the license.
  */
-package org.apache.logging.log4j.test.junit;
 
-import org.apache.logging.log4j.util.PropertiesUtil;
-import org.junit.platform.launcher.LauncherSession;
-import org.junit.platform.launcher.LauncherSessionListener;
+package org.apache.logging.log4j.test;
 
 /**
- * Global Log4j2 test setup.
+ * A container for per-test properties.
  */
-public class Log4j2LauncherSessionListener implements LauncherSessionListener {
+public interface TestProperties {
 
-    @Override
-    public void launcherSessionOpened(LauncherSession session) {
-        // Prevents `PropertiesUtil` from initializing (and caching the results)
-        // in the middle of a test.
-        PropertiesUtil.getProperties();
+    String getProperty(final String key);
+
+    boolean containsProperty(final String key);
+
+    void setProperty(final String key, final String value);
+
+    default void setProperty(final String key, final boolean value) {
+        setProperty(key, value ? "true" : "false");
+    }
+
+    default void setProperty(final String key, final int value) {
+        setProperty(key, Integer.toString(value));
     }
 
+    void clearProperty(final String key);
 }
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/ExtensionContextAnchor.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/ExtensionContextAnchor.java
index 0b4cc50d60..25c3117171 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/ExtensionContextAnchor.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/ExtensionContextAnchor.java
@@ -32,7 +32,7 @@ public class ExtensionContextAnchor
     public static Namespace LOG4J2_NAMESPACE = Namespace.create("org.apache.logging.log4j.junit");
     private static final ThreadLocal<ExtensionContext> EXTENSION_CONTEXT = new InheritableThreadLocal<>();
 
-    private static void bind(ExtensionContext context) {
+    static void bind(ExtensionContext context) {
         EXTENSION_CONTEXT.set(context);
     }
 
@@ -48,19 +48,19 @@ public class ExtensionContextAnchor
         return context != null ? context : EXTENSION_CONTEXT.get();
     }
 
-    static <T> T getAttribute(Object key, Class<T> clazz, ExtensionContext context) {
+    public static <T> T getAttribute(Object key, Class<T> clazz, ExtensionContext context) {
         final ExtensionContext actualContext = getContext(context);
         assertNotNull(actualContext, "missing ExtensionContext");
         return actualContext.getStore(LOG4J2_NAMESPACE).get(key, clazz);
     }
 
-    static void setAttribute(Object key, Object value, ExtensionContext context) {
+    public static void setAttribute(Object key, Object value, ExtensionContext context) {
         final ExtensionContext actualContext = getContext(context);
         assertNotNull(actualContext, "missing ExtensionContext");
         actualContext.getStore(LOG4J2_NAMESPACE).put(key, value);
     }
 
-    static void removeAttribute(Object key, ExtensionContext context) {
+    public static void removeAttribute(Object key, ExtensionContext context) {
         final ExtensionContext actualContext = getContext(context);
         if (actualContext != null) {
             actualContext.getStore(LOG4J2_NAMESPACE).remove(key);
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
index 51abd82afe..c88aa37d73 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
@@ -16,6 +16,11 @@
  */
 package org.apache.logging.log4j.test.junit;
 
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.status.StatusConsoleListener;
+import org.apache.logging.log4j.status.StatusData;
+import org.apache.logging.log4j.status.StatusListener;
+import org.apache.logging.log4j.status.StatusLogger;
 import org.apache.logging.log4j.util.PropertiesUtil;
 import org.junit.platform.launcher.LauncherSession;
 import org.junit.platform.launcher.LauncherSessionListener;
@@ -25,11 +30,37 @@ import org.junit.platform.launcher.LauncherSessionListener;
  */
 public class Log4j2LauncherSessionListener implements LauncherSessionListener {
 
+    private static final String DISABLE_CONSOLE_STATUS_LISTENER = "log4j2.junit.disableConsoleStatusListener";
+
     @Override
     public void launcherSessionOpened(LauncherSession session) {
         // Prevents `PropertiesUtil` from initializing (and caching the results)
         // in the middle of a test.
-        PropertiesUtil.getProperties();
+        final PropertiesUtil properties = PropertiesUtil.getProperties();
+        if (properties.getBooleanProperty(DISABLE_CONSOLE_STATUS_LISTENER)) {
+            replaceStatusConsoleListener();
+        }
+    }
+
+    private static void replaceStatusConsoleListener() {
+        final StatusLogger logger = StatusLogger.getLogger();
+        for (final StatusListener listener : logger.getListeners()) {
+            if (listener instanceof StatusConsoleListener) {
+                logger.removeListener(listener);
+            }
+        }
+        logger.registerListener(new NoOpStatusConsoleListener());
+    }
+
+    private static class NoOpStatusConsoleListener extends StatusConsoleListener {
+
+        public NoOpStatusConsoleListener() {
+            super(Level.OFF);
+        }
+
+        public void log(final StatusData data) {
+            // NOP
+        }
     }
 
 }
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/SetTestProperty.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/SetTestProperty.java
new file mode 100644
index 0000000000..d277125255
--- /dev/null
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/SetTestProperty.java
@@ -0,0 +1,59 @@
+/*
+ * 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.logging.log4j.test.junit;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * Registers a Log4j2 system property with the {@link TestPropertySource}. The
+ * property will also be available in configuration files using the
+ * {@code ${test:...} lookup.
+ *
+ */
+@Retention(RUNTIME)
+@Target({ TYPE, METHOD })
+@Inherited
+@Documented
+@ExtendWith(ExtensionContextAnchor.class)
+@ExtendWith(TestPropertyResolver.class)
+@Repeatable(SetTestProperty.SetTestProperties.class)
+public @interface SetTestProperty {
+
+    String key();
+
+    String value();
+
+    @Retention(RUNTIME)
+    @Target({ TYPE, METHOD })
+    @Documented
+    @Inherited
+    public @interface SetTestProperties {
+
+        SetTestProperty[] value();
+    }
+}
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/StatusLoggerExtension.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/StatusLoggerExtension.java
new file mode 100644
index 0000000000..e2cfdacfa8
--- /dev/null
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/StatusLoggerExtension.java
@@ -0,0 +1,134 @@
+/*
+ * 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.logging.log4j.test.junit;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.status.StatusConsoleListener;
+import org.apache.logging.log4j.status.StatusData;
+import org.apache.logging.log4j.status.StatusListener;
+import org.apache.logging.log4j.status.StatusLogger;
+import org.apache.logging.log4j.test.StatusMessages;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;
+import org.junit.jupiter.api.extension.support.TypeBasedParameterResolver;
+
+public class StatusLoggerExtension extends TypeBasedParameterResolver<StatusMessages>
+        implements BeforeEachCallback, TestExecutionExceptionHandler {
+
+    private static final StatusLogger LOGGER = StatusLogger.getLogger();
+    private static final StatusConsoleListener CONSOLE_LISTENER = new StatusConsoleListener(Level.ALL);
+    private static final Object KEY = StatusMessages.class;
+
+    public StatusLoggerExtension() {
+    }
+
+    @Override
+    public void beforeEach(ExtensionContext context) throws Exception {
+        ExtensionContextAnchor.bind(context);
+        final StatusMessagesHolder holder = new StatusMessagesHolder(context);
+        ExtensionContextAnchor.setAttribute(KEY, holder, context);
+    }
+
+    @Override
+    public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
+        printStatusMessages(context);
+        throw throwable;
+    }
+
+    public void printStatusMessages(ExtensionContext context) {
+        final StatusMessages statusListener = getStatusMessages(context);
+        statusListener.getMessages().forEach(CONSOLE_LISTENER::log);
+    }
+
+    @Override
+    public StatusMessages resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
+            throws ParameterResolutionException {
+        return getStatusMessages(extensionContext);
+    }
+
+    private static StatusMessages getStatusMessages(ExtensionContext extensionContext) {
+        final StatusMessagesHolder holder = ExtensionContextAnchor.getAttribute(KEY, StatusMessagesHolder.class,
+                extensionContext);
+        return holder.get();
+    }
+
+    private static class StatusMessagesHolder implements CloseableResource, Supplier<StatusMessages> {
+
+        private final JUnitStatusMessages statusListener;
+
+        public StatusMessagesHolder(ExtensionContext context) {
+            this.statusListener = new JUnitStatusMessages(context);
+            LOGGER.registerListener(statusListener);
+        }
+
+        @Override
+        public StatusMessages get() {
+            return statusListener;
+        }
+
+        @Override
+        public void close() throws Throwable {
+            LOGGER.removeListener(statusListener);
+        }
+
+    }
+
+    private static class JUnitStatusMessages implements StatusMessages, StatusListener {
+
+        private final ExtensionContext context;
+        private final List<StatusData> statusData = Collections.synchronizedList(new ArrayList<StatusData>());
+
+        public JUnitStatusMessages(ExtensionContext context) {
+            this.context = context;
+        }
+
+        @Override
+        public void log(StatusData data) {
+            if (context.equals(ExtensionContextAnchor.getContext())) {
+                statusData.add(data);
+            }
+        }
+
+        @Override
+        public Level getStatusLevel() {
+            return Level.DEBUG;
+        }
+
+        @Override
+        public void close() throws IOException {
+            // NOP
+        }
+
+        @Override
+        public Stream<StatusData> getMessages() {
+            return statusData.stream();
+        }
+
+    }
+}
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/TestPropertyResolver.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/TestPropertyResolver.java
new file mode 100644
index 0000000000..7c7e9177af
--- /dev/null
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/TestPropertyResolver.java
@@ -0,0 +1,75 @@
+/*
+ * 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.logging.log4j.test.junit;
+
+import java.lang.reflect.Modifier;
+
+import org.apache.logging.log4j.test.TestProperties;
+import org.apache.logging.log4j.util.ReflectionUtil;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+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.support.TypeBasedParameterResolver;
+import org.junit.platform.commons.util.AnnotationUtils;
+
+public class TestPropertyResolver extends TypeBasedParameterResolver<TestProperties>
+        implements BeforeAllCallback, BeforeEachCallback {
+
+    @Override
+    public void beforeEach(ExtensionContext context) throws Exception {
+        final TestProperties props = TestPropertySource.createProperties(context);
+        final SetTestProperty[] setProperties = context.getRequiredTestMethod()
+                .getAnnotationsByType(SetTestProperty.class);
+        if (setProperties.length > 0) {
+            for (final SetTestProperty setProperty : setProperties) {
+                props.setProperty(setProperty.key(), setProperty.value());
+            }
+        }
+        final Class<?> testClass = context.getRequiredTestClass();
+        Object testInstance = context.getRequiredTestInstance();
+        AnnotationUtils
+                .findAnnotatedFields(testClass, UsingTestProperties.class,
+                        field -> !Modifier.isStatic(field.getModifiers()))
+                .forEach(field -> ReflectionUtil.setFieldValue(field, testInstance, props));
+    }
+
+    @Override
+    public void beforeAll(ExtensionContext context) throws Exception {
+        final TestProperties props = TestPropertySource.createProperties(context);
+        final SetTestProperty[] setProperties = context.getRequiredTestClass()
+                .getAnnotationsByType(SetTestProperty.class);
+        if (setProperties.length > 0) {
+            for (final SetTestProperty setProperty : setProperties) {
+                props.setProperty(setProperty.key(), setProperty.value());
+            }
+        }
+        final Class<?> testClass = context.getRequiredTestClass();
+        AnnotationUtils
+                .findAnnotatedFields(testClass, UsingTestProperties.class,
+                        field -> Modifier.isStatic(field.getModifiers()))
+                .forEach(field -> ReflectionUtil.setStaticFieldValue(field, props));
+    }
+
+    @Override
+    public TestProperties resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
+            throws ParameterResolutionException {
+        return TestPropertySource.createProperties(extensionContext);
+    }
+}
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/TestPropertySource.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/TestPropertySource.java
new file mode 100644
index 0000000000..92547a463f
--- /dev/null
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/TestPropertySource.java
@@ -0,0 +1,141 @@
+/*
+ * 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.logging.log4j.test.junit;
+
+import org.apache.logging.log4j.test.TestProperties;
+import org.apache.logging.log4j.util.PropertySource;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
+import org.junit.jupiter.api.extension.ExtensionContext.Store;
+
+public class TestPropertySource implements PropertySource {
+
+    private static final String PREFIX = "log4j2.";
+    private static final Namespace NAMESPACE = ExtensionContextAnchor.LOG4J2_NAMESPACE.append("properties");
+    private static final TestProperties EMPTY_PROPERTIES = new EmptyTestProperties();
+
+    @Override
+    public int getPriority() {
+        // Highest priority
+        return Integer.MIN_VALUE;
+    }
+
+    public static TestProperties createProperties(ExtensionContext context) {
+        TestProperties props = getProperties(context);
+        // Make sure that the properties do not come from the parent ExtensionContext
+        if (props instanceof JUnitTestProperties && context.equals(((JUnitTestProperties) props).getContext())) {
+            return props;
+        }
+        props = new JUnitTestProperties(context);
+        ExtensionContextAnchor.setAttribute(TestProperties.class, props, context);
+        return props;
+    }
+
+    private static TestProperties getProperties() {
+        return getProperties(null);
+    }
+
+    private static TestProperties getProperties(ExtensionContext context) {
+        final ExtensionContext actualContext = context != null ? context : ExtensionContextAnchor.getContext();
+        if (actualContext != null) {
+            TestProperties props = ExtensionContextAnchor.getAttribute(TestProperties.class, TestProperties.class,
+                    actualContext);
+            if (props != null) {
+                return props;
+            }
+        }
+        return EMPTY_PROPERTIES;
+    }
+
+    @Override
+    public CharSequence getNormalForm(Iterable<? extends CharSequence> tokens) {
+        final CharSequence camelCase = Util.joinAsCamelCase(tokens);
+        // Do not use Strings to prevent recursive initialization
+        return camelCase.length() > 0 ? PREFIX + camelCase.toString() : null;
+    }
+
+    @Override
+    public String getProperty(String key) {
+        return getProperties().getProperty(key);
+    }
+
+    @Override
+    public boolean containsProperty(String key) {
+        return getProperties().containsProperty(key);
+    }
+
+    private static class JUnitTestProperties implements TestProperties {
+
+        private final ExtensionContext context;
+        private final Store store;
+
+        public JUnitTestProperties(ExtensionContext context) {
+            this.context = context;
+            this.store = context.getStore(NAMESPACE);
+        }
+
+        public ExtensionContext getContext() {
+            return context;
+        }
+
+        @Override
+        public String getProperty(String key) {
+            return store.get(key, String.class);
+        }
+
+        @Override
+        public boolean containsProperty(String key) {
+            return getProperty(key) != null;
+        }
+
+        @Override
+        public void setProperty(String key, String value) {
+            store.put(key, value);
+        }
+
+        @Override
+        public void clearProperty(String key) {
+            store.remove(key, String.class);
+        }
+
+    }
+
+    private static class EmptyTestProperties implements TestProperties {
+
+        @Override
+        public String getProperty(String key) {
+            return null;
+        }
+
+        @Override
+        public boolean containsProperty(String key) {
+            return false;
+        }
+
+        @Override
+        public void setProperty(String key, String value) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void clearProperty(String key) {
+            throw new UnsupportedOperationException();
+        }
+
+    }
+}
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/UsingStatusLogger.java
similarity index 53%
copy from log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
copy to log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/UsingStatusLogger.java
index 51abd82afe..875973415d 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/UsingStatusLogger.java
@@ -14,22 +14,29 @@
  * See the license for the specific language governing permissions and
  * limitations under the license.
  */
+
 package org.apache.logging.log4j.test.junit;
 
-import org.apache.logging.log4j.util.PropertiesUtil;
-import org.junit.platform.launcher.LauncherSession;
-import org.junit.platform.launcher.LauncherSessionListener;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.junit.jupiter.api.extension.ExtendWith;
 
 /**
- * Global Log4j2 test setup.
+ * Marks a test class as using {@link org.apache.logging.log4j.ThreadContext} APIs. This will automatically clear and restore
+ * both the thread context map and stack for each test invocation.
+ *
+ * @since 2.14.0
  */
-public class Log4j2LauncherSessionListener implements LauncherSessionListener {
-
-    @Override
-    public void launcherSessionOpened(LauncherSession session) {
-        // Prevents `PropertiesUtil` from initializing (and caching the results)
-        // in the middle of a test.
-        PropertiesUtil.getProperties();
-    }
-
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE})
+@Documented
+@Inherited
+@ExtendWith(ExtensionContextAnchor.class)
+@ExtendWith(StatusLoggerExtension.class)
+public @interface UsingStatusLogger {
 }
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/UsingTestProperties.java
similarity index 50%
copy from log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
copy to log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/UsingTestProperties.java
index 51abd82afe..eb9ab84267 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/Log4j2LauncherSessionListener.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/UsingTestProperties.java
@@ -14,22 +14,32 @@
  * See the license for the specific language governing permissions and
  * limitations under the license.
  */
+
 package org.apache.logging.log4j.test.junit;
 
-import org.apache.logging.log4j.util.PropertiesUtil;
-import org.junit.platform.launcher.LauncherSession;
-import org.junit.platform.launcher.LauncherSessionListener;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-/**
- * Global Log4j2 test setup.
- */
-public class Log4j2LauncherSessionListener implements LauncherSessionListener {
+import java.lang.annotation.Documented;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
 
-    @Override
-    public void launcherSessionOpened(LauncherSession session) {
-        // Prevents `PropertiesUtil` from initializing (and caching the results)
-        // in the middle of a test.
-        PropertiesUtil.getProperties();
-    }
+import org.apache.logging.log4j.test.TestProperties;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junitpioneer.jupiter.ReadsSystemProperty;
 
+/**
+ * A field or method parameter of type {@link TestProperties} will be injected with a per-test source of Log4j2's
+ * system properties.
+ */
+@Retention(RUNTIME)
+@Target({ FIELD, METHOD })
+@Inherited
+@Documented
+@ExtendWith(ExtensionContextAnchor.class)
+@ExtendWith(TestPropertyResolver.class)
+@ReadsSystemProperty
+public @interface UsingTestProperties {
 }
diff --git a/log4j-api-test/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/log4j-api-test/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
index ca7ce84edd..a28e0eea70 100644
--- a/log4j-api-test/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
+++ b/log4j-api-test/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
@@ -12,4 +12,6 @@
 # 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.
-org.apache.logging.log4j.test.junit.ExtensionContextAnchor
\ No newline at end of file
+
+org.apache.logging.log4j.test.junit.ExtensionContextAnchor
+org.apache.logging.log4j.test.junit.StatusLoggerExtension
\ No newline at end of file