diff --git a/pom.xml b/pom.xml index 2ae7333..f81e35c 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ - 3.5.0-SNAPSHOT + 4.0.0-SNAPSHOT 17 1.18.30 diff --git a/src/main/java/de/dm/infrastructure/logcapture/CapturingAppender.java b/src/main/java/de/dm/infrastructure/logcapture/CapturingAppender.java index 46ef844..88d71f1 100644 --- a/src/main/java/de/dm/infrastructure/logcapture/CapturingAppender.java +++ b/src/main/java/de/dm/infrastructure/logcapture/CapturingAppender.java @@ -44,6 +44,7 @@ public synchronized void doAppend(ILoggingEvent loggingEvent) { .mdcData(loggingEvent.getMDCPropertyMap()) .loggedException(getLoggedException(loggingEvent.getThrowableProxy())) .markers(loggingEvent.getMarkerList()) + .keyValuePairs(loggingEvent.getKeyValuePairs()) .build()); } } diff --git a/src/main/java/de/dm/infrastructure/logcapture/ExpectedKeyValue.java b/src/main/java/de/dm/infrastructure/logcapture/ExpectedKeyValue.java new file mode 100644 index 0000000..7bbd1ac --- /dev/null +++ b/src/main/java/de/dm/infrastructure/logcapture/ExpectedKeyValue.java @@ -0,0 +1,74 @@ +package de.dm.infrastructure.logcapture; + +import java.util.Objects; +import java.util.stream.Collectors; + +import static java.lang.String.format; +import static java.lang.System.lineSeparator; + +/** + * define expected key-value pair to be attached to a log message + */ +public final class ExpectedKeyValue implements LogEventMatcher { + private final String key; + private final Object value; + + private ExpectedKeyValue(String key, Object value) { + if (key == null || value == null) { + throw new IllegalArgumentException("key and value are required for key-value log assertion"); + } + this.key = key; + this.value = value; + } + + @Override + public boolean matches(LoggedEvent loggedEvent) { + return loggedEvent.getKeyValuePairs() != null && + loggedEvent.getKeyValuePairs().stream() + .anyMatch(pair -> key.equals(pair.key) && ( + Objects.equals(value, pair.value) || + areEqualAsNumbers(value, pair.value) + )); + } + + /* + * this is only done for Numbers because + * 1. toString() should not be expensive for these + * 2. when Logging 2L can be considered equal to 2, for example, but maybe not to 2.0 + */ + private boolean areEqualAsNumbers(Object expectedValue, Object actualValue) { + return expectedValue instanceof Number && actualValue instanceof Number && + expectedValue.toString().equals(actualValue.toString()); + } + + @Override + public String getNonMatchingErrorMessage(LoggedEvent loggedEvent) { + String expected = format(" expected key-value pair (%s, %s)", key, value) + lineSeparator(); + return expected + format(" actual pairs: [%s]", loggedEvent.getKeyValuePairs() == null ? "" : + loggedEvent.getKeyValuePairs().stream() + .map(pair -> "(%s, %s)".formatted(pair.key, pair.value)) + .collect(Collectors.joining(", "))); + } + + @Override + public String getMatcherTypeDescription() { + return "key-value pair"; + } + + @Override + public String getMatcherDetailDescription() { + return format("key-value pair (%s, %s)", key, value); + } + + /** + * use this in a log expectation to verify that something has been logged with a certain key-value pair + * + * @param key expected key + * @param value expected value + * + * @return expected key-value to use in log expectation + */ + public static ExpectedKeyValue keyValue(String key, Object value) { + return new ExpectedKeyValue(key, value); + } +} diff --git a/src/main/java/de/dm/infrastructure/logcapture/LoggedEvent.java b/src/main/java/de/dm/infrastructure/logcapture/LoggedEvent.java index 6934b19..be3947c 100644 --- a/src/main/java/de/dm/infrastructure/logcapture/LoggedEvent.java +++ b/src/main/java/de/dm/infrastructure/logcapture/LoggedEvent.java @@ -5,6 +5,7 @@ import lombok.Builder; import lombok.Getter; import org.slf4j.Marker; +import org.slf4j.event.KeyValuePair; import java.util.List; import java.util.Map; @@ -25,6 +26,7 @@ public class LoggedEvent { private final Optional loggedException; private final String loggerName; private final List markers; + private final List keyValuePairs; @SuppressWarnings("squid:S2166") // LoggedException is not an Exception, but the name is still appropriate @AllArgsConstructor(access = PRIVATE) diff --git a/src/test/java/com/example/app/ReadableApiTest.java b/src/test/java/com/example/app/ReadableApiTest.java index 48fc951..be9a6b5 100644 --- a/src/test/java/com/example/app/ReadableApiTest.java +++ b/src/test/java/com/example/app/ReadableApiTest.java @@ -9,7 +9,11 @@ import org.slf4j.Marker; import org.slf4j.MarkerFactory; +import java.math.BigDecimal; +import java.util.concurrent.atomic.AtomicInteger; + import static de.dm.infrastructure.logcapture.ExpectedException.exception; +import static de.dm.infrastructure.logcapture.ExpectedKeyValue.keyValue; import static de.dm.infrastructure.logcapture.ExpectedLoggerName.logger; import static de.dm.infrastructure.logcapture.ExpectedMarker.marker; import static de.dm.infrastructure.logcapture.ExpectedMdcEntry.mdc; @@ -21,6 +25,7 @@ import static de.dm.infrastructure.logcapture.LogExpectation.warn; import static java.lang.System.lineSeparator; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; @Slf4j @@ -718,4 +723,137 @@ void combinedLogExpectationsOnlyOutputMismatch() { lineSeparator() + " actual exception: message: \"an exception that was logged\", message: java.lang.RuntimeException" + lineSeparator()); } + + @Nested + class ExpectedKeyValues { + @Test + void failsWithDetailsNotMatchingExistingKeyValues() { + log.atInfo().setMessage("hello") + .addKeyValue("key1", 1) + .addKeyValue("key2", "value2") + .log(); + + var assertionError = assertThrows(AssertionError.class, () -> + logCapture.assertLogged(info("hello", keyValue("key", "a value"))) + ); + assertThat(assertionError).hasMessage(""" + Expected log message has occurred, but never with the expected key-value pair: Level: INFO, Regex: "hello" + expected key-value pair (key, a value) + actual pairs: [(key1, 1), (key2, value2)] + """); + } + + @Test + void failsWithDetailsWithNoExistingKeyValues() { + log.atInfo().setMessage("hello") + .log(); + + var assertionError = assertThrows(AssertionError.class, () -> + logCapture.assertLogged(info("hello", keyValue("key", "a value"))) + ); + assertThat(assertionError).hasMessage(""" + Expected log message has occurred, but never with the expected key-value pair: Level: INFO, Regex: "hello" + expected key-value pair (key, a value) + actual pairs: [] + """); + } + + @Test + void requiresKey() { + var assertionError = assertThrows(IllegalArgumentException.class, () -> + logCapture.assertLogged(info("hello", keyValue(null, "a value"))) + ); + assertThat(assertionError).hasMessage("key and value are required for key-value log assertion"); + } + + @Test + void requiresValue() { + var assertionError = assertThrows(IllegalArgumentException.class, () -> + logCapture.assertLogged(info("hello", keyValue("a_key", null))) + ); + assertThat(assertionError).hasMessage("key and value are required for key-value log assertion"); + } + + @Test + void succeedsWithString() { + log.atInfo().setMessage("hello") + .addKeyValue("name", "Frederick") + .log(); + + assertDoesNotThrow(() -> + logCapture.assertLogged(info("hello", keyValue("name", "Frederick"))) + ); + } + + @Test + void succeedsWithLong() { + log.atInfo().setMessage("hello") + .addKeyValue("meaning", 42L) + .log(); + + assertDoesNotThrow(() -> + logCapture.assertLogged(info("hello", keyValue("meaning", 42L))) + ); + } + + @Test + void succeedsWithLongAndInt() { + log.atInfo().setMessage("hello") + .addKeyValue("meaning", 42) + .log(); + + assertDoesNotThrow(() -> + logCapture.assertLogged(info("hello", keyValue("meaning", 42L))) + ); + } + + @Test + void succeedsWithIntAndLong() { + log.atInfo().setMessage("hello") + .addKeyValue("meaning", 42L) + .log(); + + assertDoesNotThrow(() -> + logCapture.assertLogged(info("hello", keyValue("meaning", 42))) + ); + } + + @Test + void succeedsWithIntAndAtomicInt() { + log.atInfo().setMessage("hello") + .addKeyValue("meaning", new AtomicInteger(42)) + .log(); + + assertDoesNotThrow(() -> + logCapture.assertLogged(info("hello", keyValue("meaning", 42))) + ); + } + + @Test + void failsWithBigDecimalAndPrecisionAndInt() { + log.atInfo().setMessage("hello") + .addKeyValue("meaning", new BigDecimal("42.00")) + .log(); + + var assertionError = assertThrows(AssertionError.class, () -> + logCapture.assertLogged(info("hello", keyValue("meaning", 42))) + ); + assertThat(assertionError).hasMessage(""" + Expected log message has occurred, but never with the expected key-value pair: Level: INFO, Regex: "hello" + expected key-value pair (meaning, 42) + actual pairs: [(meaning, 42.00)] + """); + } + + @Test + void succeedsWithBigDecimalAndInt() { + log.atInfo().setMessage("hello") + .addKeyValue("meaning", new BigDecimal("42")) + .log(); + + assertDoesNotThrow(() -> + logCapture.assertLogged(info("hello", keyValue("meaning", 42))) + ); + } + } }