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(); + } + } + } +}