Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create utils and Jupiter extension to reset Logback #468

Merged
merged 16 commits into from
Feb 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.kiwiproject.test.junit.jupiter;

import com.google.common.annotations.VisibleForTesting;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.kiwiproject.test.logback.LogbackTestHelper;

/**
* A JUnit Jupiter {@link org.junit.jupiter.api.extension.Extension Extension} to reset
* the Logback logging system after all tests have completed.
* <p>
* This is useful if something misbehaves, for example Dropwizard's
* <a href="https://www.dropwizard.io/en/stable/manual/testing.html#integration-testing">DropwizardAppExtension</a> and
* <a href="https://www.dropwizard.io/en/stable/manual/testing.html#testing-client-implementations">DropwizardClientExtension</a>
* extensions both stop and detach all appenders after all tests complete! Both of those extensions
* reset Logback in
* <a href="https://github.com/dropwizard/dropwizard/blob/297870e3b4b43ea9fb19417dd90ed78151cf6f5d/dropwizard-testing/src/main/java/io/dropwizard/testing/DropwizardTestSupport.java#L244">DropwizardTestSupport</a>.
* Once this happens, there is no logging output from subsequent tests (since there are no more appenders).
* We consider this to be <em>bad</em>, since logging output is useful to track down causes if
* there are other test failures. And, it's just not nice behavior to completely hijack logging!
* <p>
* You can use this extension in tests that are using misbehaving components to ensure that Logback
* is reset after all tests complete, so that subsequent tests have log output.
* <p>
* For example to use the default {@code logback-test.xml} as the logging configuration you
* can just use {@code @ExtendWith} on the test class:
* <pre>
* {@literal @}ExtendWith(DropwizardExtensionsSupport.class)
* {@literal @}ExtendWith(ResetLogbackLoggingExtension.class)
* class CustomClientTest {
*
* // test code that uses DropwizardClientExtension
* }
* </pre>
* Alternatively, you can register the extension programmatically to use a custom
* logging configuration:
* <pre>
* {@literal @}ExtendWith(DropwizardExtensionsSupport.class)
* class CustomClientTest {
*
* {@literal @}RegisterExtension
* static final ResetLogbackLoggingExtension RESET_LOGBACK = ResetLogbackLoggingExtension.builder()
* .logbackConfigFilePath("acme-special-logback.xml")
* .build();
*
* // test code that uses DropwizardClientExtension
* }
* </pre>
*/
@Builder
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PACKAGE)
@Slf4j
@SuppressWarnings("LombokGetterMayBeUsed")
public class ResetLogbackLoggingExtension implements AfterAllCallback {

/**
* A custom location for the Logback configuration.
* <p>
* If this is not set, then the default Logback configuration files are used
* in the order {@code logback-test.xml} followed by {@code logback.xml}.
*/
@Getter
private String logbackConfigFilePath;

@Override
public void afterAll(ExtensionContext context) {
getLogbackTestHelper().resetLogbackWithDefaultOrConfig(logbackConfigFilePath);
LOG.debug("Logback was reset using configuration: {} (if null, the Logback defaults are used)",
logbackConfigFilePath);
}

@VisibleForTesting
protected LogbackTestHelper getLogbackTestHelper() {
return new LogbackTestHelper();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,11 @@
import static java.util.Objects.nonNull;
import static org.assertj.core.api.Assertions.assertThat;

import ch.qos.logback.classic.ClassicConstants;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.joran.JoranConfigurator;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.Appender;
import ch.qos.logback.core.joran.spi.JoranException;
import com.google.common.annotations.Beta;
import com.google.common.io.Resources;
import com.google.common.annotations.VisibleForTesting;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.checkerframework.checker.nullness.qual.Nullable;
Expand All @@ -37,8 +33,8 @@ public class InMemoryAppenderExtension implements BeforeEachCallback, AfterEachC
private final Class<?> loggerClass;
private final String appenderName;

// Use the default Logback test configuration file as our default value.
private String logbackConfigFilePath = ClassicConstants.TEST_AUTOCONFIG_FILE;
@Getter
private String logbackConfigFilePath;

@Getter
@Accessors(fluent = true)
Expand Down Expand Up @@ -106,16 +102,20 @@ public InMemoryAppenderExtension(Class<?> loggerClass, String appenderName) {
}

/**
* The Logback configuration to use if the logging system needs to be reset.
* The custom Logback configuration to use if the logging system needs to be reset.
* <p>
* For example:
*
* <pre>
* {@literal @}RegisterExtension
* private final InMemoryAppenderExtension inMemoryAppenderExtension =
* new InMemoryAppenderExtension(InMemoryAppenderTest.class)
* .withLogbackConfigFilePath("acme-logback-test.xml");
* </pre>
*
* If this is not set, then the default Logback configuration files are used
* in the order {@code logback-test.xml} followed by {@code logback.xml}.
*
* @param logbackConfigFilePath the location of the custom Logback configuration file
* @return this extension, so this can be chained after the constructor
* @see <a href="https://github.com/kiwiproject/kiwi-test/issues/457">Tests failing because Logback appenders don't exist (#457)</a>
Expand Down Expand Up @@ -153,7 +153,7 @@ public InMemoryAppenderExtension withLogbackConfigFilePath(String logbackConfigF
* @param context the current extension context; never {@code null}
*/
@Override
public void beforeEach(ExtensionContext context) throws Exception {
public void beforeEach(ExtensionContext context) {
var logbackLogger = (Logger) LoggerFactory.getLogger(loggerClass);
var rawAppender = getAppender(logbackLogger);

Expand All @@ -165,8 +165,9 @@ public void beforeEach(ExtensionContext context) throws Exception {
}

@Nullable
@VisibleForTesting
@SuppressWarnings("java:S106")
private Appender<ILoggingEvent> getAppender(Logger logbackLogger) throws JoranException {
Appender<ILoggingEvent> getAppender(Logger logbackLogger) {
var rawAppender = logbackLogger.getAppender(appenderName);

if (nonNull(rawAppender)) {
Expand All @@ -180,20 +181,18 @@ private Appender<ILoggingEvent> getAppender(Logger logbackLogger) throws JoranEx
System.out.println("You can customize the logging configuration using #withLogbackConfigFilePath");

// Reset the Logback logging system
var loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
loggerContext.stop();

var joranConfigurator = new JoranConfigurator();
joranConfigurator.setContext(loggerContext);
var logbackConfigUrl = Resources.getResource(logbackConfigFilePath);
joranConfigurator.doConfigure(logbackConfigUrl);
loggerContext.start();
getLogbackTestHelper().resetLogbackWithDefaultOrConfig(logbackConfigFilePath);

// Try again and return whatever we get. It should not be null after resetting, unless
// the reset failed, or the appender was not configured correctly.
return logbackLogger.getAppender(appenderName);
}

@VisibleForTesting
protected LogbackTestHelper getLogbackTestHelper() {
return new LogbackTestHelper();
}

/**
* Clears all logging events from the {@link InMemoryAppender} to ensure each
* test starts with an empty appender.
Expand Down
46 changes: 46 additions & 0 deletions src/main/java/org/kiwiproject/test/logback/LogbackTestHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.kiwiproject.test.logback;

import static org.apache.commons.lang3.StringUtils.isBlank;

import org.checkerframework.checker.nullness.qual.Nullable;

/**
* Provides utilities for Logback-related functionality.
* <p>
* This is an instance-based utility class, and is mainly useful if you need to mock
* its behavior. By default, it delegates to {@link LogbackTestHelpers} for methods
* that have the same signature.
*/
public class LogbackTestHelper {

/**
* Resets Logback using either the given config file, or uses the defaults
* as provided by {@link LogbackTestHelpers#resetLogback()}.
*
* @param logbackConfigFile the Logback config file to use, or null
*/
public void resetLogbackWithDefaultOrConfig(@Nullable String logbackConfigFile) {
if (isBlank(logbackConfigFile)) {
resetLogback();
} else {
resetLogback(logbackConfigFile);
}
}

/**
* Delegates to {@link LogbackTestHelpers#resetLogback()}.
*/
public void resetLogback() {
LogbackTestHelpers.resetLogback();
}

/**
* Delegates to {@link LogbackTestHelpers#resetLogback(String, String...)}.
*
* @param logbackConfigFile the location of the custom Logback configuration file
* @param fallbackConfigFiles additional locations to check for Logback configuration files
*/
public void resetLogback(String logbackConfigFile, String... fallbackConfigFiles) {
LogbackTestHelpers.resetLogback(logbackConfigFile, fallbackConfigFiles);
}
}
96 changes: 96 additions & 0 deletions src/main/java/org/kiwiproject/test/logback/LogbackTestHelpers.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package org.kiwiproject.test.logback;

import static com.google.common.base.Preconditions.checkArgument;
import static org.apache.commons.lang3.StringUtils.isNoneBlank;
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotBlank;
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotNull;

import ch.qos.logback.classic.ClassicConstants;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.joran.JoranConfigurator;
import ch.qos.logback.core.joran.spi.JoranException;
import com.google.common.io.Resources;
import lombok.experimental.UtilityClass;
import org.apache.commons.collections4.ListUtils;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.LoggerFactory;

import java.net.URL;
import java.util.List;
import java.util.Objects;

/**
* Static test utilities that provide Logback-related functionality.
*/
@UtilityClass
public class LogbackTestHelpers {

/**
* Reset the Logback logging system using the default test configuration file ({@code logback-test.xml}).
* If that doesn't exist, try to fall back to the default configuration file ({@code logback.xml}).
* <p>
* If you need a custom location (or locations), use {@link #resetLogback(String, String...)}.
*
* @throws UncheckedJoranException if an error occurs resetting Logback
* @see ClassicConstants#TEST_AUTOCONFIG_FILE
* @see ClassicConstants#AUTOCONFIG_FILE
*/
public static void resetLogback() {
resetLogback(ClassicConstants.TEST_AUTOCONFIG_FILE, ClassicConstants.AUTOCONFIG_FILE);
}

/**
* Reset the Logback logging system using the given configuration file.
* If the primary file does not exist, use the first fallback configuration
* file that exists. If the reset fails, an exception is thrown immediately.
* <p>
* The fallback configurations are searched in the order they are provided.
*
* @param logbackConfigFile the location of the custom Logback configuration file
* @param fallbackConfigFiles additional locations to check for Logback configuration files
* @throws UncheckedJoranException if an error occurs resetting Logback
* @throws IllegalArgumentException if none of the Logback configuration files exist
*/
public static void resetLogback(String logbackConfigFile, String... fallbackConfigFiles) {
checkArgumentNotBlank(logbackConfigFile, "logbackConfigFile must not be blank");
checkArgumentNotNull(fallbackConfigFiles, "fallback locations vararg parameter must not be null");
checkArgument(isNoneBlank(fallbackConfigFiles), "fallbackConfigFiles must not contain blank locations");

try {
var logbackConfigUrl = getFirstLogbackConfigOrThrow(logbackConfigFile, fallbackConfigFiles);

var loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
loggerContext.stop();

var joranConfigurator = new JoranConfigurator();
joranConfigurator.setContext(loggerContext);
joranConfigurator.doConfigure(logbackConfigUrl);
loggerContext.start();
} catch (JoranException e) {
throw new UncheckedJoranException(e);
}
}

private static URL getFirstLogbackConfigOrThrow(String logbackConfigFilePath, String... fallbackConfigFilePaths) {
var allConfigs = ListUtils.union(
List.of(logbackConfigFilePath),
List.of(fallbackConfigFilePaths)
);

return allConfigs.stream()
.map(LogbackTestHelpers::getResourceOrNull)
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() ->
new IllegalArgumentException("Did not find any of the Logback configurations: " + allConfigs));
}

@Nullable
private static URL getResourceOrNull(String resourceName) {
try {
return Resources.getResource(resourceName);
} catch (IllegalArgumentException e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.kiwiproject.test.logback;

import static org.kiwiproject.base.KiwiPreconditions.requireNotNull;

import ch.qos.logback.core.joran.spi.JoranException;

/**
* Wraps a {@link JoranException} with an unchecked exception.
*/
public class UncheckedJoranException extends RuntimeException {

/**
* Construct an instance.
*
* @param message the message, which can be null
* @param cause the cause, which cannot be null
* @throws IllegalArgumentException if cause is null
*/
public UncheckedJoranException(String message, JoranException cause) {
super(message, requireNotNull(cause));
}

/**
* Construct an instance.
*
* @param cause the cause, which cannot be null
* @throws IllegalArgumentException if cause is null
*/
public UncheckedJoranException(JoranException cause) {
super(requireNotNull(cause));
}

/**
* Returns the cause of this exception.
*
* @return the {@link JoranException} which is the cause of this exception
*/
@Override
public synchronized JoranException getCause() {
return (JoranException) super.getCause();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.kiwiproject.test.junit.jupiter.ResetLogbackLoggingExtension;

import java.util.List;

@DisplayName("DropwizardAppTests")
@ExtendWith(SoftAssertionsExtension.class)
@ExtendWith(DropwizardExtensionsSupport.class)
@ExtendWith(ResetLogbackLoggingExtension.class)
class DropwizardAppTestsTest {

@Getter
Expand Down
Loading