diff --git a/src/main/java/org/jvnet/hudson/test/LogRecorder.java b/src/main/java/org/jvnet/hudson/test/LogRecorder.java
new file mode 100644
index 000000000..fc0dece32
--- /dev/null
+++ b/src/main/java/org/jvnet/hudson/test/LogRecorder.java
@@ -0,0 +1,335 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2025 Jenkins project contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package org.jvnet.hudson.test;
+
+import edu.umd.cs.findbugs.annotations.CheckForNull;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.util.RingBufferLogHandler;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Formatter;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import java.util.logging.SimpleFormatter;
+import java.util.stream.Collectors;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+/**
+ * A test utility which allows you to easily enable one or more loggers for the duration of a test.
+ * Call {@link #record(Class, Level)} or another overload for the recording to take effect.
+ *
+ * By default, messages are merely printed to test output.
+ * If you also want to examine them, call {@link #capture}.
+ *
+ * See the following example:
+ *
+ * try (LogRecorder recorder = new LogRecorder().record("Foo", Level.INFO).capture(100)) {
+ * LOGGER.log(Level.INFO, "Log Message");
+ * assertThat(recorder, LogRecorder.recorded(equalTo("Log Message")));
+ * }
+ *
+ */
+public class LogRecorder implements AutoCloseable {
+
+ private final Handler consoleHandler = new ConsoleHandlerWithMaxLevel();
+ private final Map loggers = new HashMap<>();
+ // initialized if and only if capture is called:
+ private RingBufferLogHandler ringHandler;
+ private List messages;
+ private boolean verbose = true;
+
+ /**
+ * Initializes the recorder, by default not recording anything.
+ */
+ public LogRecorder() {
+ consoleHandler.setFormatter(new DeltaSupportLogFormatter());
+ consoleHandler.setLevel(Level.ALL);
+ }
+
+ /**
+ * Don't emit logs to the console, only record.
+ */
+ public LogRecorder quiet() {
+ this.verbose = false;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return getRecords().stream()
+ .map(logRecord -> logRecord.getLevel().toString() + "->" + logRecord.getMessage())
+ .collect(Collectors.joining(","));
+ }
+
+ /**
+ * Initializes log record capture, in addition to merely printing it.
+ * This allows you to call {@link #getRecords} and/or {@link #getMessages} later.
+ * @param maximum the maximum number of records to keep (any further will be discarded)
+ * @return this recorder, for convenience
+ */
+ public LogRecorder capture(int maximum) {
+ messages = new ArrayList<>();
+ ringHandler = new RingBufferLogHandler(maximum) {
+ final Formatter f =
+ new SimpleFormatter(); // placeholder instance for what should have been a static method perhaps
+
+ @Override
+ public synchronized void publish(LogRecord record) {
+ super.publish(record);
+ String message = f.formatMessage(record);
+ Throwable x = record.getThrown();
+ synchronized (messages) {
+ messages.add(message == null && x != null ? x.toString() : message);
+ }
+ }
+ };
+ ringHandler.setLevel(Level.ALL);
+ for (Logger logger : loggers.keySet()) {
+ logger.addHandler(ringHandler);
+ }
+ return this;
+ }
+
+ /**
+ * Start listening to a logger.
+ * @param logger some logger
+ * @param level something between {@link Level#CONFIG} and {@link Level#ALL};
+ * using {@link Level#INFO} or above is typically senseless,
+ * since Java will by default log everything at such levels anyway;
+ * unless you wish to inspect visible {@link #getRecords},
+ * or wish to suppress console log output for some logger
+ * @return this recorder, for convenience
+ */
+ public LogRecorder record(Logger logger, Level level) {
+ loggers.put(logger, logger.getLevel());
+ logger.setLevel(level);
+ if (verbose) {
+ logger.addHandler(consoleHandler);
+ }
+ if (ringHandler != null) {
+ logger.addHandler(ringHandler);
+ }
+ return this;
+ }
+
+ /**
+ * Same as {@link #record(Logger, Level)} but calls {@link Logger#getLogger(String)} for you first.
+ */
+ public LogRecorder record(String name, Level level) {
+ return record(Logger.getLogger(name), level);
+ }
+
+ /**
+ * Same as {@link #record(String, Level)} but calls {@link Class#getName()} for you first.
+ */
+ public LogRecorder record(Class> clazz, Level level) {
+ return record(clazz.getName(), level);
+ }
+
+ /**
+ * Same as {@link #record(String, Level)} but calls {@link Class#getPackage()} and getName() for you first.
+ */
+ public LogRecorder recordPackage(Class> clazz, Level level) {
+ return record(clazz.getPackage().getName(), level);
+ }
+
+ Map getRecordedLevels() {
+ return loggers.keySet().stream().collect(Collectors.toMap(Logger::getName, Logger::getLevel));
+ }
+
+ /**
+ * Obtains all log records collected so far during this test case.
+ * You must have first called {@link #capture}.
+ * If more than the maximum number of records were captured, older ones will have been discarded.
+ */
+ public List getRecords() {
+ return ringHandler.getView();
+ }
+
+ /**
+ * Returns a read-only view of current messages.
+ *
+ * {@link Formatter#formatMessage} applied to {@link #getRecords} at the time of logging.
+ * However, if the message is null, but there is an exception, {@link Throwable#toString} will be used.
+ * Does not include logger names, stack traces, times, etc. (these will appear in the test console anyway).
+ */
+ public List getMessages() {
+ synchronized (messages) {
+ return List.copyOf(messages);
+ }
+ }
+
+ @Override
+ public void close() {
+ for (Map.Entry entry : loggers.entrySet()) {
+ Logger logger = entry.getKey();
+ logger.setLevel(entry.getValue());
+ if (verbose) {
+ logger.removeHandler(consoleHandler);
+ }
+ if (ringHandler != null) {
+ logger.removeHandler(ringHandler);
+ }
+ }
+ loggers.clear();
+ if (ringHandler != null) {
+ ringHandler.clear();
+ messages.clear();
+ }
+ }
+
+ /**
+ * Creates a {@link Matcher} that matches if the {@link LogRecorder} has a {@link LogRecord} at
+ * the specified {@link Level}, with a message matching the specified matcher, and with a
+ * {@link Throwable} matching the specified matcher.
+ * You must have first called {@link #capture}.
+ *
+ * @param level The {@link Level} of the {@link LogRecorder} to match. Pass {@code null} to match any {@link Level}.
+ * @param message the matcher to match against {@link LogRecord#getMessage}
+ * @param thrown the matcher to match against {@link LogRecord#getThrown()}. Passing {@code null} is equivalent to
+ * passing {@link org.hamcrest.Matchers#anything}
+ */
+ public static Matcher recorded(
+ @CheckForNull Level level, @NonNull Matcher message, @CheckForNull Matcher thrown) {
+ return new RecordedMatcher(level, message, thrown);
+ }
+
+ /**
+ * Creates a {@link Matcher} that matches if the {@link LogRecorder} has a {@link LogRecord} at
+ * the specified {@link Level} and with a message matching the specified matcher.
+ * You must have first called {@link #capture}.
+ *
+ * @param level The {@link Level} of the {@link LogRecorder} to match. Pass {@code null} to match any {@link Level}.
+ * @param message The matcher to match against {@link LogRecord#getMessage}.
+ */
+ public static Matcher recorded(@CheckForNull Level level, @NonNull Matcher message) {
+ return recorded(level, message, null);
+ }
+
+ /**
+ * Creates a {@link Matcher} that matches if the {@link LogRecorder} has a {@link LogRecord}
+ * with a message matching the specified matcher and with a {@link Throwable} matching the specified
+ * matcher.
+ * You must have first called {@link #capture}.
+ *
+ * @param message the matcher to match against {@link LogRecord#getMessage}
+ * @param thrown the matcher to match against {@link LogRecord#getThrown()}. Passing {@code null} is equivalent to
+ * passing {@link org.hamcrest.Matchers#anything}
+ */
+ public static Matcher recorded(
+ @NonNull Matcher message, @CheckForNull Matcher thrown) {
+ return recorded(null, message, thrown);
+ }
+
+ /**
+ * Creates a {@link Matcher} that matches if the {@link LogRecorder} has a {@link LogRecord}
+ * with a message matching the specified matcher.
+ * You must have first called {@link #capture}.
+ *
+ * @param message the matcher to match against {@link LogRecord#getMessage}
+ */
+ public static Matcher recorded(@NonNull Matcher message) {
+ return recorded(null, message);
+ }
+
+ static class RecordedMatcher extends TypeSafeMatcher {
+ @CheckForNull
+ Level level;
+
+ @NonNull
+ Matcher message;
+
+ @CheckForNull
+ Matcher thrown;
+
+ public RecordedMatcher(
+ @CheckForNull Level level, @NonNull Matcher message, @CheckForNull Matcher thrown) {
+ this.level = level;
+ this.message = message;
+ this.thrown = thrown;
+ }
+
+ @Override
+ protected boolean matchesSafely(LogRecorder item) {
+ synchronized (item) {
+ for (LogRecord record : item.getRecords()) {
+ if (level == null || record.getLevel() == level) {
+ if (message.matches(record.getMessage())) {
+ if (thrown != null) {
+ if (thrown.matches(record.getThrown())) {
+ return true;
+ }
+ } else {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("has LogRecord");
+ if (level != null) {
+ description.appendText(" with level ");
+ description.appendValue(level.getName());
+ }
+ description.appendText(" with a message matching ");
+ description.appendDescriptionOf(message);
+ if (thrown != null) {
+ description.appendText(" with an exception matching ");
+ description.appendDescriptionOf(thrown);
+ }
+ }
+ }
+
+ /**
+ * Delegates to the given Handler but filter out records higher or equal to its initial level
+ */
+ private static class ConsoleHandlerWithMaxLevel extends ConsoleHandler {
+ private final Level initialLevel;
+
+ public ConsoleHandlerWithMaxLevel() {
+ super();
+ initialLevel = getLevel();
+ }
+
+ @Override
+ public void publish(LogRecord record) {
+ if (record.getLevel().intValue() < initialLevel.intValue()) {
+ super.publish(record);
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/jvnet/hudson/test/LoggerRule.java b/src/main/java/org/jvnet/hudson/test/LoggerRule.java
index 918e745fa..fcd6cb1a1 100644
--- a/src/main/java/org/jvnet/hudson/test/LoggerRule.java
+++ b/src/main/java/org/jvnet/hudson/test/LoggerRule.java
@@ -26,19 +26,14 @@
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
-import hudson.util.RingBufferLogHandler;
-import java.util.ArrayList;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.logging.ConsoleHandler;
import java.util.logging.Formatter;
-import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
-import java.util.logging.SimpleFormatter;
-import java.util.stream.Collectors;
+
+import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.ClassRule;
@@ -57,35 +52,19 @@
*/
public class LoggerRule extends ExternalResource {
- private final Handler consoleHandler = new ConsoleHandlerWithMaxLevel();
- private final Map loggers = new HashMap<>();
- // initialized if and only if capture is called:
- private RingBufferLogHandler ringHandler;
- private List messages;
- private boolean verbose = true;
-
- /**
- * Initializes the rule, by default not recording anything.
- */
- public LoggerRule() {
- consoleHandler.setFormatter(new DeltaSupportLogFormatter());
- consoleHandler.setLevel(Level.ALL);
- }
+ private final LogRecorder recorder = new LogRecorder();
/**
* Don't emit logs to the console, only record.
*/
public LoggerRule quiet() {
- this.verbose = false;
+ recorder.quiet();
return this;
}
@Override
public String toString() {
- return getRecords()
- .stream()
- .map(logRecord -> logRecord.getLevel().toString() + "->" + logRecord.getMessage())
- .collect(Collectors.joining(","));
+ return recorder.toString();
}
/**
@@ -95,23 +74,7 @@ public String toString() {
* @return this rule, for convenience
*/
public LoggerRule capture(int maximum) {
- messages = new ArrayList<>();
- ringHandler = new RingBufferLogHandler(maximum) {
- final Formatter f = new SimpleFormatter(); // placeholder instance for what should have been a static method perhaps
- @Override
- public synchronized void publish(LogRecord record) {
- super.publish(record);
- String message = f.formatMessage(record);
- Throwable x = record.getThrown();
- synchronized (messages) {
- messages.add(message == null && x != null ? x.toString() : message);
- }
- }
- };
- ringHandler.setLevel(Level.ALL);
- for (Logger logger : loggers.keySet()) {
- logger.addHandler(ringHandler);
- }
+ recorder.capture(maximum);
return this;
}
@@ -128,14 +91,7 @@ public synchronized void publish(LogRecord record) {
* @return this rule, for convenience
*/
public LoggerRule record(Logger logger, Level level) {
- loggers.put(logger, logger.getLevel());
- logger.setLevel(level);
- if (verbose) {
- logger.addHandler(consoleHandler);
- }
- if (ringHandler != null) {
- logger.addHandler(ringHandler);
- }
+ recorder.record(logger, level);
return this;
}
@@ -143,25 +99,28 @@ public LoggerRule record(Logger logger, Level level) {
* Same as {@link #record(Logger, Level)} but calls {@link Logger#getLogger(String)} for you first.
*/
public LoggerRule record(String name, Level level) {
- return record(Logger.getLogger(name), level);
+ recorder.record(Logger.getLogger(name), level);
+ return this;
}
-
+
/**
* Same as {@link #record(String, Level)} but calls {@link Class#getName()} for you first.
*/
public LoggerRule record(Class> clazz, Level level) {
- return record(clazz.getName(), level);
+ recorder.record(clazz.getName(), level);
+ return this;
}
/**
* Same as {@link #record(String, Level)} but calls {@link Class#getPackage()} and getName() for you first.
*/
public LoggerRule recordPackage(Class> clazz, Level level) {
- return record(clazz.getPackage().getName(), level);
+ recorder.record(clazz.getPackage().getName(), level);
+ return this;
}
Map getRecordedLevels() {
- return loggers.keySet().stream().collect(Collectors.toMap(Logger::getName, Logger::getLevel));
+ return recorder.getRecordedLevels();
}
/**
@@ -170,7 +129,7 @@ Map getRecordedLevels() {
* If more than the maximum number of records were captured, older ones will have been discarded.
*/
public List getRecords() {
- return ringHandler.getView();
+ return recorder.getRecords();
}
/**
@@ -181,28 +140,12 @@ public List getRecords() {
* Does not include logger names, stack traces, times, etc. (these will appear in the test console anyway).
*/
public List getMessages() {
- synchronized (messages) {
- return List.copyOf(messages);
- }
+ return recorder.getMessages();
}
@Override
protected void after() {
- for (Map.Entry entry : loggers.entrySet()) {
- Logger logger = entry.getKey();
- logger.setLevel(entry.getValue());
- if (verbose) {
- logger.removeHandler(consoleHandler);
- }
- if (ringHandler != null) {
- logger.removeHandler(ringHandler);
- }
- }
- loggers.clear();
- if (ringHandler != null) {
- ringHandler.clear();
- messages.clear();
- }
+ recorder.close();
}
/**
@@ -216,8 +159,22 @@ protected void after() {
* @param thrown the matcher to match against {@link LogRecord#getThrown()}. Passing {@code null} is equivalent to
* passing {@link org.hamcrest.Matchers#anything}
*/
- public static Matcher recorded(@CheckForNull Level level, @NonNull Matcher message, @CheckForNull Matcher thrown) {
- return new RecordedMatcher(level, message, thrown);
+ public static Matcher recorded(
+ @CheckForNull Level level, @NonNull Matcher message, @CheckForNull Matcher thrown) {
+ return new TypeSafeMatcher<>() {
+
+ private final LogRecorder.RecordedMatcher matcher = new LogRecorder.RecordedMatcher(level, message, thrown);
+
+ @Override
+ public void describeTo(Description description) {
+ matcher.describeTo(description);
+ }
+
+ @Override
+ protected boolean matchesSafely(LoggerRule loggerRule) {
+ return matcher.matches(loggerRule.recorder);
+ }
+ };
}
/**
@@ -242,7 +199,8 @@ public static Matcher recorded(@CheckForNull Level level, @NonNull M
* @param thrown the matcher to match against {@link LogRecord#getThrown()}. Passing {@code null} is equivalent to
* passing {@link org.hamcrest.Matchers#anything}
*/
- public static Matcher recorded(@NonNull Matcher message, @CheckForNull Matcher thrown) {
+ public static Matcher recorded(
+ @NonNull Matcher message, @CheckForNull Matcher thrown) {
return recorded(null, message, thrown);
}
@@ -254,72 +212,6 @@ public static Matcher recorded(@NonNull Matcher message, @Ch
* @param message the matcher to match against {@link LogRecord#getMessage}
*/
public static Matcher recorded(@NonNull Matcher message) {
- return recorded(null, message);
- }
-
- private static class RecordedMatcher extends TypeSafeMatcher {
- @CheckForNull Level level;
- @NonNull Matcher message;
- @CheckForNull Matcher thrown;
-
- public RecordedMatcher(@CheckForNull Level level, @NonNull Matcher message, @CheckForNull Matcher thrown) {
- this.level = level;
- this.message = message;
- this.thrown = thrown;
- }
-
- @Override
- protected boolean matchesSafely(LoggerRule item) {
- synchronized (item) {
- for (LogRecord record : item.getRecords()) {
- if (level == null || record.getLevel() == level) {
- if (message.matches(record.getMessage())) {
- if (thrown != null) {
- if (thrown.matches(record.getThrown())) {
- return true;
- }
- } else {
- return true;
- }
- }
- }
- }
- }
- return false;
- }
-
- @Override
- public void describeTo(org.hamcrest.Description description) {
- description.appendText("has LogRecord");
- if (level != null) {
- description.appendText(" with level ");
- description.appendValue(level.getName());
- }
- description.appendText(" with a message matching ");
- description.appendDescriptionOf(message);
- if (thrown != null) {
- description.appendText(" with an exception matching ");
- description.appendDescriptionOf(thrown);
- }
- }
- }
-
- /**
- * Delegates to the given Handler but filter out records higher or equal to its initial level
- */
- private static class ConsoleHandlerWithMaxLevel extends ConsoleHandler {
- private final Level initialLevel;
-
- public ConsoleHandlerWithMaxLevel() {
- super();
- initialLevel = getLevel();
- }
-
- @Override
- public void publish(LogRecord record) {
- if (record.getLevel().intValue() < initialLevel.intValue()) {
- super.publish(record);
- }
- }
+ return recorded(null, message, null);
}
}
diff --git a/src/test/java/org/jvnet/hudson/test/LogRecorderTest.java b/src/test/java/org/jvnet/hudson/test/LogRecorderTest.java
new file mode 100644
index 000000000..e72056de3
--- /dev/null
+++ b/src/test/java/org/jvnet/hudson/test/LogRecorderTest.java
@@ -0,0 +1,153 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2025 Jenkins project contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.jvnet.hudson.test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.not;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.junit.jupiter.api.Test;
+
+class LogRecorderTest {
+
+ private static final Logger FOO_LOGGER = Logger.getLogger("Foo");
+ private static final Logger BAR_LOGGER = Logger.getLogger("Bar");
+
+ @Test
+ void testRecordedSingleLogger() {
+ try (LogRecorder logRecorder =
+ new LogRecorder().record("Foo", Level.INFO).capture(1)) {
+ FOO_LOGGER.log(Level.INFO, "Entry 1");
+ assertThat(logRecorder, LogRecorder.recorded(Level.INFO, equalTo("Entry 1")));
+ assertThat(logRecorder, not(LogRecorder.recorded(Level.WARNING, equalTo("Entry 1"))));
+ FOO_LOGGER.log(Level.INFO, "Entry 2");
+ assertThat(logRecorder, not(LogRecorder.recorded(equalTo("Entry 1"))));
+ assertThat(logRecorder, LogRecorder.recorded(equalTo("Entry 2")));
+ }
+ }
+
+ @Test
+ void assertionErrorMatchesExpectedText() {
+ try (LogRecorder logRecorder =
+ new LogRecorder().record("Foo", Level.INFO).capture(2)) {
+ FOO_LOGGER.log(Level.INFO, "Entry 1");
+ FOO_LOGGER.log(Level.INFO, "Entry 3");
+ AssertionError assertionError = assertThrows(
+ AssertionError.class,
+ () -> assertThat(logRecorder, LogRecorder.recorded(Level.INFO, equalTo("Entry 2"))));
+
+ assertThat(
+ assertionError.getMessage(),
+ containsString("Expected: has LogRecord with level \"INFO\" with a message matching \"Entry 2\""));
+ assertThat(assertionError.getMessage(), containsString(" but: was Entry 3,INFO->Entry 1>"));
+ }
+ }
+
+ @Test
+ void testRecordedMultipleLoggers() {
+ try (LogRecorder logRecorder = new LogRecorder()
+ .record("Foo", Level.INFO)
+ .record("Bar", Level.SEVERE)
+ .capture(2)) {
+ FOO_LOGGER.log(Level.INFO, "Foo Entry 1");
+ BAR_LOGGER.log(Level.SEVERE, "Bar Entry 1");
+ assertThat(logRecorder, LogRecorder.recorded(equalTo("Foo Entry 1")));
+ assertThat(logRecorder, LogRecorder.recorded(equalTo("Bar Entry 1")));
+ // All criteria must match a single LogRecord.
+ assertThat(logRecorder, not(LogRecorder.recorded(Level.INFO, equalTo("Bar Entry 1"))));
+ }
+ }
+
+ @Test
+ void testRecordedThrowable() {
+ try (LogRecorder logRecorder =
+ new LogRecorder().record("Foo", Level.INFO).capture(1)) {
+ FOO_LOGGER.log(Level.INFO, "Foo Entry 1", new IllegalStateException());
+ assertThat(
+ logRecorder, LogRecorder.recorded(equalTo("Foo Entry 1"), instanceOf(IllegalStateException.class)));
+ assertThat(
+ logRecorder,
+ LogRecorder.recorded(Level.INFO, equalTo("Foo Entry 1"), instanceOf(IllegalStateException.class)));
+ assertThat(
+ logRecorder,
+ not(LogRecorder.recorded(Level.INFO, equalTo("Foo Entry 1"), instanceOf(IOException.class))));
+ }
+ }
+
+ @Test
+ void testRecordedNoShortCircuit() {
+ try (LogRecorder logRecorder =
+ new LogRecorder().record("Foo", Level.INFO).capture(2)) {
+ FOO_LOGGER.log(Level.INFO, "Foo Entry", new IllegalStateException());
+ FOO_LOGGER.log(Level.INFO, "Foo Entry", new IOException());
+ assertThat(
+ logRecorder,
+ LogRecorder.recorded(Level.INFO, equalTo("Foo Entry"), instanceOf(IllegalStateException.class)));
+ assertThat(
+ logRecorder, LogRecorder.recorded(Level.INFO, equalTo("Foo Entry"), instanceOf(IOException.class)));
+ }
+ }
+
+ @Test
+ void multipleThreads() throws InterruptedException {
+ AtomicBoolean active = new AtomicBoolean(true);
+ try (LogRecorder logRecorder =
+ new LogRecorder().record("Foo", Level.INFO).capture(1000)) {
+ Thread thread = new Thread("logging stuff") {
+ @Override
+ public void run() {
+ try {
+ int i = 1;
+ while (active.get()) {
+ FOO_LOGGER.log(Level.INFO, "Foo Entry " + i++);
+ Thread.sleep(50);
+ }
+ } catch (InterruptedException x) {
+ // stopped
+ }
+ }
+ };
+ try {
+ thread.setDaemon(true);
+ thread.start();
+ Thread.sleep(500);
+ for (String message : logRecorder.getMessages()) {
+ assertNotNull(message);
+ Thread.sleep(50);
+ }
+ } finally {
+ active.set(false);
+ thread.interrupt();
+ }
+ }
+ }
+}