diff --git a/src/main/java/org/kiwiproject/test/junit/jupiter/ResetLogbackLoggingExtension.java b/src/main/java/org/kiwiproject/test/junit/jupiter/ResetLogbackLoggingExtension.java new file mode 100644 index 00000000..2bbe4320 --- /dev/null +++ b/src/main/java/org/kiwiproject/test/junit/jupiter/ResetLogbackLoggingExtension.java @@ -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. + *

+ * This is useful if something misbehaves, for example Dropwizard's + * DropwizardAppExtension and + * DropwizardClientExtension + * extensions both stop and detach all appenders after all tests complete! Both of those extensions + * reset Logback in + * DropwizardTestSupport. + * Once this happens, there is no logging output from subsequent tests (since there are no more appenders). + * We consider this to be bad, 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! + *

+ * 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. + *

+ * For example to use the default {@code logback-test.xml} as the logging configuration you + * can just use {@code @ExtendWith} on the test class: + *

+ *  {@literal @}ExtendWith(DropwizardExtensionsSupport.class)
+ *  {@literal @}ExtendWith(ResetLogbackLoggingExtension.class)
+ *   class CustomClientTest {
+ *
+ *       // test code that uses DropwizardClientExtension
+ *   }
+ * 
+ * Alternatively, you can register the extension programmatically to use a custom + * logging configuration: + *
+ *  {@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
+ *   }
+ * 
+ */ +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PACKAGE) +@Slf4j +@SuppressWarnings("LombokGetterMayBeUsed") +public class ResetLogbackLoggingExtension implements AfterAllCallback { + + /** + * A custom location for the Logback configuration. + *

+ * 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(); + } +} diff --git a/src/main/java/org/kiwiproject/test/logback/InMemoryAppenderExtension.java b/src/main/java/org/kiwiproject/test/logback/InMemoryAppenderExtension.java index fe73a66b..befb383c 100644 --- a/src/main/java/org/kiwiproject/test/logback/InMemoryAppenderExtension.java +++ b/src/main/java/org/kiwiproject/test/logback/InMemoryAppenderExtension.java @@ -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; @@ -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) @@ -106,9 +102,10 @@ 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. *

* For example: + * *

      * {@literal @}RegisterExtension
      *  private final InMemoryAppenderExtension inMemoryAppenderExtension =
@@ -116,6 +113,9 @@ public InMemoryAppenderExtension(Class loggerClass, String appenderName) {
      *                  .withLogbackConfigFilePath("acme-logback-test.xml");
      * 
* + * 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 Tests failing because Logback appenders don't exist (#457) @@ -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); @@ -165,8 +165,9 @@ public void beforeEach(ExtensionContext context) throws Exception { } @Nullable + @VisibleForTesting @SuppressWarnings("java:S106") - private Appender getAppender(Logger logbackLogger) throws JoranException { + Appender getAppender(Logger logbackLogger) { var rawAppender = logbackLogger.getAppender(appenderName); if (nonNull(rawAppender)) { @@ -180,20 +181,18 @@ private Appender 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. diff --git a/src/main/java/org/kiwiproject/test/logback/LogbackTestHelper.java b/src/main/java/org/kiwiproject/test/logback/LogbackTestHelper.java new file mode 100644 index 00000000..d25d1fc4 --- /dev/null +++ b/src/main/java/org/kiwiproject/test/logback/LogbackTestHelper.java @@ -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. + *

+ * 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); + } +} diff --git a/src/main/java/org/kiwiproject/test/logback/LogbackTestHelpers.java b/src/main/java/org/kiwiproject/test/logback/LogbackTestHelpers.java new file mode 100644 index 00000000..35c18080 --- /dev/null +++ b/src/main/java/org/kiwiproject/test/logback/LogbackTestHelpers.java @@ -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}). + *

+ * 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. + *

+ * 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; + } + } +} diff --git a/src/main/java/org/kiwiproject/test/logback/UncheckedJoranException.java b/src/main/java/org/kiwiproject/test/logback/UncheckedJoranException.java new file mode 100644 index 00000000..b7b18d70 --- /dev/null +++ b/src/main/java/org/kiwiproject/test/logback/UncheckedJoranException.java @@ -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(); + } +} diff --git a/src/test/java/org/kiwiproject/test/dropwizard/app/DropwizardAppTestsTest.java b/src/test/java/org/kiwiproject/test/dropwizard/app/DropwizardAppTestsTest.java index 7ff50e78..0c8f9061 100644 --- a/src/test/java/org/kiwiproject/test/dropwizard/app/DropwizardAppTestsTest.java +++ b/src/test/java/org/kiwiproject/test/dropwizard/app/DropwizardAppTestsTest.java @@ -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 diff --git a/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionConfigOverridesTest.java b/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionConfigOverridesTest.java index a7b9662b..a2ca64fc 100644 --- a/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionConfigOverridesTest.java +++ b/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionConfigOverridesTest.java @@ -18,14 +18,17 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtensionContext; +import org.kiwiproject.test.junit.jupiter.ResetLogbackLoggingExtension; @DisplayName("PostgresAppTestExtension: ConfigOverrides") +@ExtendWith(ResetLogbackLoggingExtension.class) class PostgresAppTestExtensionConfigOverridesTest { @Getter @Setter - static class Config extends Configuration { + public static class Config extends Configuration { @Valid @NotNull @JsonProperty("database") diff --git a/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionCustomPropertyTest.java b/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionCustomPropertyTest.java index 56b47c25..e0854313 100644 --- a/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionCustomPropertyTest.java +++ b/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionCustomPropertyTest.java @@ -16,17 +16,20 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtensionContext; +import org.kiwiproject.test.junit.jupiter.ResetLogbackLoggingExtension; /** * Tests that we can specify a custom name in the configuration for the DataSourceFactory. */ @DisplayName("PostgresAppTestExtension (custom DataSourceFactory property") +@ExtendWith(ResetLogbackLoggingExtension.class) class PostgresAppTestExtensionCustomPropertyTest { @Getter @Setter - static class Config extends Configuration { + public static class Config extends Configuration { @Valid @NotNull @JsonProperty("db") diff --git a/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionTest.java b/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionTest.java index 783afb46..42170a59 100644 --- a/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionTest.java +++ b/src/test/java/org/kiwiproject/test/dropwizard/app/PostgresAppTestExtensionTest.java @@ -16,14 +16,17 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtensionContext; +import org.kiwiproject.test.junit.jupiter.ResetLogbackLoggingExtension; @DisplayName("PostgresAppTestExtension") +@ExtendWith(ResetLogbackLoggingExtension.class) class PostgresAppTestExtensionTest { @Getter @Setter - static class Config extends Configuration { + public static class Config extends Configuration { @Valid @NotNull @JsonProperty("database") diff --git a/src/test/java/org/kiwiproject/test/junit/jupiter/Jdbi3DaoExtensionTest.java b/src/test/java/org/kiwiproject/test/junit/jupiter/Jdbi3DaoExtensionTest.java index d21b5f70..d6ed6820 100644 --- a/src/test/java/org/kiwiproject/test/junit/jupiter/Jdbi3DaoExtensionTest.java +++ b/src/test/java/org/kiwiproject/test/junit/jupiter/Jdbi3DaoExtensionTest.java @@ -110,7 +110,7 @@ void shouldNotBeAffectedByPreviousFailure() { } @Value - private static class TestTableValue { + public static class TestTableValue { String col1; int col2; } diff --git a/src/test/java/org/kiwiproject/test/junit/jupiter/ResetLogbackLoggingExtensionTest.java b/src/test/java/org/kiwiproject/test/junit/jupiter/ResetLogbackLoggingExtensionTest.java new file mode 100644 index 00000000..cfa4b9a1 --- /dev/null +++ b/src/test/java/org/kiwiproject/test/junit/jupiter/ResetLogbackLoggingExtensionTest.java @@ -0,0 +1,33 @@ +package org.kiwiproject.test.junit.jupiter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("ResetLogbackLoggingExtension") +class ResetLogbackLoggingExtensionTest { + + @Test + void shouldConstructWithNullAsDefaultConfigLocation() { + var extension = new ResetLogbackLoggingExtension(); + assertThat(extension.getLogbackConfigFilePath()).isNull(); + } + + @Test + void shouldBuildWithNullAsDefaultConfigLocation() { + var extension = ResetLogbackLoggingExtension.builder().build(); + assertThat(extension.getLogbackConfigFilePath()).isNull(); + } + + @Test + void shouldAllowCustomConfigLocation() { + var customLocation = "acme-test-logback.xml"; + + var extension = ResetLogbackLoggingExtension.builder() + .logbackConfigFilePath(customLocation) + .build(); + + assertThat(extension.getLogbackConfigFilePath()).isEqualTo(customLocation); + } +} diff --git a/src/test/java/org/kiwiproject/test/logback/InMemoryAppenderExtensionTest.java b/src/test/java/org/kiwiproject/test/logback/InMemoryAppenderExtensionTest.java new file mode 100644 index 00000000..6f1cff48 --- /dev/null +++ b/src/test/java/org/kiwiproject/test/logback/InMemoryAppenderExtensionTest.java @@ -0,0 +1,99 @@ +package org.kiwiproject.test.logback; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.only; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Logger; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.kiwiproject.test.junit.jupiter.ClearBoxTest; + +@DisplayName("InMemoryAppenderExtension") +@Slf4j +class InMemoryAppenderExtensionTest { + + @Test + void shouldUseLogbackTestFileAsDefaultConfigLocation() { + var extension = new InMemoryAppenderExtension(InMemoryAppenderExtensionTest.class); + assertThat(extension.getLogbackConfigFilePath()).isNull(); + } + + @Test + void shouldAcceptCustomConfigLocation() { + var customLocation = "acme-test-logback.xml"; + var extension = new InMemoryAppenderExtension(InMemoryAppenderExtensionTest.class) + .withLogbackConfigFilePath(customLocation); + + assertThat(extension.getLogbackConfigFilePath()).isEqualTo(customLocation); + } + + @ClearBoxTest + void shouldReturnNewLogbackTestHelperInstances() { + var extension = new InMemoryAppenderExtension(InMemoryAppenderExtensionTest.class); + + var helper1 = extension.getLogbackTestHelper(); + var helper2 = extension.getLogbackTestHelper(); + var helper3 = extension.getLogbackTestHelper(); + + assertThat(helper1).isNotSameAs(helper2); + assertThat(helper2).isNotSameAs(helper3); + } + + @ClearBoxTest + void shouldGetAppenderWhenExists() { + var logbackLogger = mock(Logger.class); + var appender = new InMemoryAppender(); + when(logbackLogger.getAppender(anyString())).thenReturn(appender); + + var extension = new InMemoryAppenderExtension(InMemoryAppenderExtensionTest.class); + + var returnedAppender = extension.getAppender(logbackLogger); + assertThat(returnedAppender).isSameAs(appender); + + verify(logbackLogger, only()).getAppender(expectedAppenderName()); + } + + private static String expectedAppenderName() { + return InMemoryAppenderExtensionTest.class.getSimpleName() + "Appender"; + } + + @ClearBoxTest + void shouldResetLogbackWhenAppenderDoesNotExist() { + var logbackLogger = mock(Logger.class); + var appender = new InMemoryAppender(); + + // Simulate initially not getting the appender + // then getting an appender (once Logback has been reset) + when(logbackLogger.getAppender(anyString())) + .thenReturn(null) + .thenReturn(appender); + + var logbackTestHelper = mock(LogbackTestHelper.class); + + var appenderName = "MyAppender"; + var extension = new InMemoryAppenderExtension(InMemoryAppenderExtensionTest.class, appenderName) { + @Override + protected LogbackTestHelper getLogbackTestHelper() { + return logbackTestHelper; + } + }; + + var customConfig = "acme-logback-test.xml"; + extension.withLogbackConfigFilePath(customConfig); + + var returnedAppender = extension.getAppender(logbackLogger); + assertThat(returnedAppender).isSameAs(appender); + + verify(logbackLogger, times(2)).getAppender(appenderName); + verifyNoMoreInteractions(logbackLogger); + + verify(logbackTestHelper, only()).resetLogbackWithDefaultOrConfig(customConfig); + } +} diff --git a/src/test/java/org/kiwiproject/test/logback/LogbackTestHelperTest.java b/src/test/java/org/kiwiproject/test/logback/LogbackTestHelperTest.java new file mode 100644 index 00000000..af2abe1a --- /dev/null +++ b/src/test/java/org/kiwiproject/test/logback/LogbackTestHelperTest.java @@ -0,0 +1,54 @@ +package org.kiwiproject.test.logback; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.kiwiproject.test.junit.jupiter.params.provider.MinimalBlankStringSource; + +@DisplayName("Logback") +class LogbackTestHelperTest { + + private LogbackTestHelper helper; + + @BeforeEach + void setUp() { + helper = spy(new LogbackTestHelper()); + + doNothing().when(helper).resetLogback(); + doNothing().when(helper).resetLogback(anyString()); + } + + @Test + void shouldResetLogbackWithCustomConfiguration() { + var customConfig = "test-acme-logback.xml"; + helper.resetLogbackWithDefaultOrConfig(customConfig); + + verify(helper).resetLogback(customConfig); + } + + @ParameterizedTest + @MinimalBlankStringSource + void shouldUseDefaultWhenCustomConfigurationIsBlank(String configFile) { + helper.resetLogbackWithDefaultOrConfig(configFile); + + verify(helper).resetLogback(); + } + + @Test + void shouldDelegateToLogbackTestHelpersWithFallback() { + // Verify the delegation without actually resetting Logback + // by passing in config files that don't exist. + + assertThatIllegalArgumentException() + .isThrownBy(() -> + new LogbackTestHelper().resetLogback("acme-test-logback.xml", "acme-logback.xml")) + .withMessage("Did not find any of the Logback configurations: [acme-test-logback.xml, acme-logback.xml]"); + } +} diff --git a/src/test/java/org/kiwiproject/test/logback/LogbackTestHelpersIntegrationTest.java b/src/test/java/org/kiwiproject/test/logback/LogbackTestHelpersIntegrationTest.java new file mode 100644 index 00000000..2a6e2af6 --- /dev/null +++ b/src/test/java/org/kiwiproject/test/logback/LogbackTestHelpersIntegrationTest.java @@ -0,0 +1,161 @@ +package org.kiwiproject.test.logback; + +import static java.util.Objects.nonNull; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import ch.qos.logback.classic.Logger; +import com.codahale.metrics.health.HealthCheck; +import io.dropwizard.core.Application; +import io.dropwizard.core.Configuration; +import io.dropwizard.core.setup.Environment; +import io.dropwizard.testing.junit5.DropwizardAppExtension; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.kiwiproject.test.junit.jupiter.ResetLogbackLoggingExtension; +import org.slf4j.LoggerFactory; + +/** + * This integration test uses DropwizardAppExtension which resets Logback when it + * starts the test application class. It first verifies that there is no appender + * for this class' Logger, and then uses {@link LogbackTestHelpers#resetLogback()} + * to reset Logback to the default logging configuration. Finally, it ensures that + * the InMemoryAppender was reset and that it receives the expected messages. + *

+ * The tests are designed to execute in a specific order and use Jupiter's + * method ordering feature. The first test executes after DropwizardAppExtension + * has reset Logback, so we expect the appender to be null. That test resets + * Logback, after which all subsequent tests should get a non-null appender. + *

+ * In case of failure, this test uses the ResetLogbackLoggingExtension to restore + * the Logback logging configuration. However, since that extension simply uses + * {@link LogbackTestHelpers}, it might not work if there is actually a bug and + * is therefore a bit circular. + */ +@DisplayName("LogbackTestHelpers (Integration Test)") +@ExtendWith(DropwizardExtensionsSupport.class) +@ExtendWith(ResetLogbackLoggingExtension.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Slf4j +class LogbackTestHelpersIntegrationTest { + + private static final String APPENDER_NAME = "LogbackTestHelpersIntegrationTestAppender"; + + public static class MyConfig extends Configuration { + } + + public static class MyApp extends Application { + + @Override + public void run(MyConfig config, Environment environment) { + // does nothing at all + + environment.healthChecks().register("noop", new HealthCheck() { + @Override + protected Result check() { + return Result.healthy("Everything's fine here, how are you?"); + } + }); + } + } + + @SuppressWarnings("unused") + static final DropwizardAppExtension APP_EXTENSION = new DropwizardAppExtension<>(MyApp.class); + + private Logger logbackLogger; + private InMemoryAppender appender; + + @BeforeEach + void setUp() { + logbackLogger = getLogbackLogger(); + appender = getAppender(); + + assertThat(logbackLogger) + .describedAs("Getting the logger as a Logback Logger should always return the same Logger instance") + .isSameAs(LOG); + } + + @AfterEach + void tearDown() { + // This must re-fetch the appender, since the instance field can be null (for the first test) + var freshAppender = getAppender(); + if (nonNull(freshAppender)) { + freshAppender.clearEvents(); + } + } + + @Test + @Order(1) + void shouldResetDefaultLoggingConfiguration() { + assertThat(appender) + .describedAs("appender should be null; DropwizardAppExtension is expected to have reset Logback") + .isNull(); + + assertThatCode(() -> { + LOG.debug("message 1"); + LOG.info("message 2"); + LOG.warn("message 3"); + LOG.error("message 4"); + }) + .describedAs("We should still be able to log things (they just won't go anywhere)") + .doesNotThrowAnyException(); + + LogbackTestHelpers.resetLogback(); + + LOG.trace("message 5"); + LOG.debug("message 6"); + LOG.info("message 7"); + LOG.warn("message 8"); + LOG.error("message 9"); + + var resetAppender = getAppender(); + assertThat(resetAppender).isNotNull(); + + assertThat(resetAppender.orderedEventMessages()).containsExactly( + "message 6","message 7","message 8", "message 9"); + } + + @Test + @Order(2) + void shouldHaveAppenderOnceReset() { + assertThat(appender) + .describedAs("appender should not be null; previous test should have reset it") + .isNotNull(); + + LOG.trace("message 0"); + LOG.debug("message A"); + LOG.info("message B"); + LOG.warn("message C"); + + assertThat(appender.orderedEventMessages()).containsExactly( + "message A", "message B", "message C"); + } + + @Test + @Order(3) + void shouldStillHaveAppender() { + assertThat(appender) + .describedAs("appender should not be null; first test should have reset it") + .isNotNull(); + + LOG.debug("message Z"); + + assertThat(appender.orderedEventMessages()).containsExactly("message Z"); + } + + private static Logger getLogbackLogger() { + return (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(LogbackTestHelpersIntegrationTest.class); + } + + private InMemoryAppender getAppender() { + return (InMemoryAppender) logbackLogger.getAppender(APPENDER_NAME); + } +} diff --git a/src/test/java/org/kiwiproject/test/logback/LogbackTestHelpersTest.java b/src/test/java/org/kiwiproject/test/logback/LogbackTestHelpersTest.java new file mode 100644 index 00000000..803f1010 --- /dev/null +++ b/src/test/java/org/kiwiproject/test/logback/LogbackTestHelpersTest.java @@ -0,0 +1,72 @@ +package org.kiwiproject.test.logback; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import ch.qos.logback.core.joran.spi.JoranException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.kiwiproject.test.junit.jupiter.ResetLogbackLoggingExtension; +import org.kiwiproject.test.junit.jupiter.params.provider.MinimalBlankStringSource; + +/** + * Unit test for {@link LogbackTestHelpers}. This mainly tests invalid arguments + * and invalid Logback configuration. {@link LogbackTestHelpersIntegrationTest} + * performs a full integration test of the reset behavior. + */ +@DisplayName("LogbackTestHelpers") +@ExtendWith(ResetLogbackLoggingExtension.class) +class LogbackTestHelpersTest { + + @Nested + class ThrowsIllegalArgumentException { + + @Test + void whenInvalidLogbackConfigPath() { + assertThatIllegalArgumentException() + .isThrownBy(() -> LogbackTestHelpers.resetLogback("dne.xml")) + .withMessage("Did not find any of the Logback configurations: [dne.xml]"); + } + + @Test + void whenInvalidLogbackConfigPaths() { + assertThatIllegalArgumentException().isThrownBy(() -> + LogbackTestHelpers.resetLogback("dne1.xml", "dne2.xml", "dne3.xml")) + .withMessage("Did not find any of the Logback configurations: [dne1.xml, dne2.xml, dne3.xml]"); + } + + @ParameterizedTest + @MinimalBlankStringSource + void whenGivenBlankConfigLocation(String location) { + assertThatIllegalArgumentException() + .isThrownBy(() -> LogbackTestHelpers.resetLogback(location)) + .withMessage("logbackConfigFile must not be blank"); + } + + @Test + void whenExplicitNullFallbackLocationsIsGiven() { + assertThatIllegalArgumentException() + .isThrownBy(() -> LogbackTestHelpers.resetLogback("acme-logback.xml", (String[]) null)) + .withMessage("fallback locations vararg parameter must not be null"); + } + + @ParameterizedTest + @MinimalBlankStringSource + void whenFallbackLocationIsBlank(String fallbackLocation) { + assertThatIllegalArgumentException() + .isThrownBy(() -> LogbackTestHelpers.resetLogback("acme-test-logback.xml", fallbackLocation)) + .withMessage("fallbackConfigFiles must not contain blank locations"); + } + } + + @Test + void shouldThrowUncheckedJoranException_WhenInvalidLogbackConfig() { + assertThatExceptionOfType(UncheckedJoranException.class) + .isThrownBy(() -> + LogbackTestHelpers.resetLogback("LogbackTestHelpersTest/invalid-logback-test.xml")) + .withCauseExactlyInstanceOf(JoranException.class); + } +} diff --git a/src/test/java/org/kiwiproject/test/logback/UncheckedJoranExceptionTest.java b/src/test/java/org/kiwiproject/test/logback/UncheckedJoranExceptionTest.java new file mode 100644 index 00000000..ca0829f1 --- /dev/null +++ b/src/test/java/org/kiwiproject/test/logback/UncheckedJoranExceptionTest.java @@ -0,0 +1,32 @@ +package org.kiwiproject.test.logback; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.junit.jupiter.api.Assertions.assertAll; + +import ch.qos.logback.core.joran.spi.JoranException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("UncheckedJoranException") +class UncheckedJoranExceptionTest { + + @SuppressWarnings("ThrowableNotThrown") + @Test + void shouldRequireCause() { + assertAll( + () -> assertThatIllegalArgumentException().isThrownBy(() -> new UncheckedJoranException("oops", null)), + () -> assertThatIllegalArgumentException().isThrownBy(() -> new UncheckedJoranException(null)) + ); + } + + @Test + void shouldSetCause() { + var joranException = new JoranException("Invalid XML"); + + assertAll( + () -> assertThat(new UncheckedJoranException("oops", joranException)).hasCause(joranException), + () -> assertThat(new UncheckedJoranException(joranException)).hasCause(joranException) + ); + } +} diff --git a/src/test/resources/LogbackTestHelpersTest/invalid-logback-test.xml b/src/test/resources/LogbackTestHelpersTest/invalid-logback-test.xml new file mode 100644 index 00000000..7dcf49f6 --- /dev/null +++ b/src/test/resources/LogbackTestHelpersTest/invalid-logback-test.xml @@ -0,0 +1,18 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{5} - %msg%n + + + + + + + + + diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index 44d3f35d..727e53fa 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -26,6 +26,14 @@ + + + + + + + +