Warning
Experimental. This library is under active development. APIs may change between minor versions without notice. Not recommended for production test suites yet — use at your own risk and pin to an exact version.
In-memory log capture with AssertJ-style assertions for JUnit 5.
Designed for Quarkus and any JBoss Log Manager environment.
SLF4J API + JUnit 5 extension + fluent AssertJ DSL.
Other approaches to log assertion in the JBoss/Quarkus ecosystem:
| Approach | Problem |
|---|---|
Logback ListAppender |
Verbose setup per test; no DSL; does not work with JBoss Log Manager |
Manual java.util.logging.Handler |
Boilerplate; you write all the assertion logic yourself |
| Quarkus built-in log capture | Quarkus-only; no standalone Java support; no fluent DSL |
log-assert is the only library offering a fluent AssertJ-style DSL over JBoss Log Manager events with full Quarkus lifecycle awareness — works in plain JUnit 5 tests and @QuarkusTest alike.
| Dependency | Minimum version |
|---|---|
| Java | 21 |
JUnit 5 (junit-jupiter) |
5.10+ |
| AssertJ | 3.24+ |
| JBoss Log Manager | 3.0+ |
| SLF4J | 2.0+ |
| Quarkus (optional) | 3.x |
<dependency>
<groupId>io.github.usmanakram232</groupId>
<artifactId>log-assert</artifactId>
<version>1.0.2</version>
<scope>test</scope>
</dependency>import static io.github.logassert.assertj.LogCaptorAssertions.assertThat;
import io.github.logassert.core.LogCaptor;
import io.github.logassert.junit5.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.*;
import org.slf4j.event.Level;
@ExtendWith(LogCaptorExtension.class)
class OrderServiceTest {
@InjectLogCaptor
LogCaptor logCaptor; // auto-injected and auto-cleared before each test
@Test
void payment_failure_is_logged() {
orderService.processPayment(invalidRequest);
assertThat(logCaptor)
.atLevel(Level.ERROR)
.fromLogger(OrderService.class)
.hasSize(1)
.single()
.hasFormattedMessageContaining("Payment rejected")
.hasThrowable(IllegalArgumentException.class);
}
}Quarkus re-initialises the JBoss Log Manager after the extension class is loaded, which would
uninstall any handler registered via @ExtendWith. Use the @RegisterExtension static form
so the extension runs after Quarkus has finished resetting the log manager:
import io.quarkus.test.junit.QuarkusTest;
import io.github.logassert.junit5.*;
import org.junit.jupiter.api.extension.RegisterExtension;
@QuarkusTest
class PaymentResourceTest {
@RegisterExtension // MUST be static
static LogCaptorExtension logExt = LogCaptorExtension.create();
@InjectLogCaptor
LogCaptor logCaptor;
@Test
void captures_payment_error() {
// ... trigger code under test ...
assertThat(logCaptor)
.atLevel(Level.ERROR)
.isNotEmpty();
}
}Why
static? JUnit 5's@RegisterExtensionon a static field registers the extension as a class-level extension (like@BeforeAll/@AfterAll), giving it a chance to install the capture handler after the Quarkus test machinery has finished setting up the log manager. A non-static field would register it as an instance-level extension, which runs too early.
Marks a LogCaptor field on a test class for automatic injection by LogCaptorExtension. The
field is set (and the captor is cleared) before each test method.
@InjectLogCaptor
LogCaptor captor;The captor can also be received as a method parameter:
@Test
void example(LogCaptor captor) { ... }When present on a test class or method, dumps all captured log entries to System.err when that
test fails. Useful for diagnosing flaky or unexpected failures without permanently enabling verbose
logging.
@ExtendWith(LogCaptorExtension.class)
@PrintLogsOnFailure // dump logs whenever any test in this class fails
class MyTest { ... }Can also be placed on a single method:
@Test
@PrintLogsOnFailure // dump logs only when this test fails
void noisy_integration_test() { ... }Re-enables console log output for the annotated test scope. By default, LogCaptorExtension
detaches all console/stream handlers from the root logger to prevent test noise. Annotating with
@EchoLogs signals that log output should still be forwarded to the console for that scope.
@EchoLogs(minimumLevel = Level.DEBUG) // echo DEBUG and above to console during this test
@Test
void debug_heavy_test() { ... }| Attribute | Default | Description |
|---|---|---|
minimumLevel |
TRACE |
Minimum SLF4J level to echo. |
Causes the test to fail in afterEach if any ERROR-level log entries remain in the captor that
were not asserted on. This is a safety net to catch unexpected error logging.
@ExtendWith(LogCaptorExtension.class)
@FailOnUncheckedError // any un-asserted ERROR entry fails the test
class StrictServiceTest { ... }The check fires in
afterEachonly when the test itself passed. If the test body already threw an exception orAssertionError,@FailOnUncheckedErroris suppressed to avoid masking the original failure. If you asserted ERROR entries in the test body, calllogCaptor.clearLogs()after your assertions to prevent a spurious@FailOnUncheckedErrorfailure.
import static io.github.logassert.assertj.LogCaptorAssertions.assertThat;LogCaptorAssertions is deliberately named to avoid clashing with AssertJ's own
org.assertj.core.api.Assertions when both are statically imported in the same class.
// From a LogCaptor (snapshot taken at call time)
assertThat(logCaptor) ...
// From a pre-fetched list
List<LogEntry> entries = logCaptor.getLogs();
assertThat(entries) ...| Method | Description |
|---|---|
atLevel(Level level) |
Keep only entries at exactly the given SLF4J level. |
atLevelAtLeast(Level minimum) |
Keep only entries at minimum severity or higher. |
fromLogger(Class<?> clazz) |
Keep only entries whose logger name starts with clazz.getName(). |
fromLogger(String prefix) |
Keep only entries whose logger name starts with prefix. |
containingMessage(String substring) |
Keep only entries whose formatted message contains substring (case-sensitive). |
withMdcEntry(String key, String value) |
Keep only entries whose MDC context has key mapped to value. |
Filters return a new LogsAssert over the narrowed list and do not mutate the original.
| Method | Description |
|---|---|
hasSize(int n) |
Assert exactly n entries. Failure message lists all captured entries. |
isEmpty() |
Assert no entries captured. |
isNotEmpty() |
Assert at least one entry captured. |
| Method | Description |
|---|---|
single() |
Assert exactly one entry, then return an assertion for it. |
first() |
Assert at least one entry, then return an assertion for the first. |
last() |
Assert at least one entry, then return an assertion for the last. |
| Method | Description |
|---|---|
hasFormattedMessage(String expected) |
Assert the fully-resolved message equals expected. |
hasFormattedMessageContaining(String substring) |
Assert the resolved message contains substring. |
hasFormattedMessageMatching(Pattern regex) |
Assert the resolved message matches the regex (uses find(), not matches()). |
hasRawTemplate(String expected) |
Assert the raw SLF4J template before {} substitution. |
Bridge note: when using
slf4j-jboss-logmanager, bothformattedMessageandrawTemplatecontain the fully resolved string. UsehasFormattedMessageContainingin all environments.
| Method | Description |
|---|---|
hasLevel(Level level) |
Assert the log level exactly. |
hasLoggerName(String name) |
Assert the fully-qualified logger name exactly. |
| Method | Description |
|---|---|
hasMdcEntry(String key, String value) |
Assert the MDC context at log time contained key → value. |
| Method | Description |
|---|---|
hasThrowable(Class<? extends Throwable> type) |
Assert a throwable was captured and its class equals type. |
hasThrowableWithMessage(String message) |
Assert a throwable was captured and its message equals message. |
hasThrowableWithMessageContaining(String substring) |
Assert a throwable was captured and its message contains substring. |
hasNoThrowable() |
Assert no throwable was attached to this log entry. |
logCaptor.getLogs() // unmodifiable snapshot at call time; call again for fresh view
logCaptor.clearLogs() // remove all captured entries (called automatically in beforeEach)
logCaptor.withMinLevel(Level) // lower effective capture level (restored in afterEach)
logCaptor.resetConfiguration() // restore original log level (called automatically in afterEach)
logCaptor.close() // resetConfiguration() + uninstall handler (called in afterAll)| Field | Type | Description |
|---|---|---|
timestamp |
Instant |
Wall-clock time of the event. |
level |
org.slf4j.event.Level |
SLF4J level (TRACE/DEBUG/INFO/WARN/ERROR). |
loggerName |
String |
Fully-qualified logger name. |
formattedMessage |
String |
Fully resolved message — {} placeholders substituted. |
rawTemplate |
String |
Raw SLF4J template before substitution (may equal formattedMessage). |
throwable |
ThrowableInfo |
Serialized exception snapshot, or null. |
mdcContext |
Map<String,String> |
Unmodifiable copy of MDC at log time. |
threadName |
String |
Name of the thread that emitted the event. |
threadId |
long |
ID of the thread that emitted the event. |
markerName |
String |
SLF4J marker name — always null in v1. |
import static io.github.logassert.assertj.LogCaptorAssertions.assertThat;
import io.github.logassert.core.LogCaptor;
import io.github.logassert.junit5.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.slf4j.event.Level;
@ExtendWith(LogCaptorExtension.class)
@FailOnUncheckedError // unexpected ERRORs will fail the test
@PrintLogsOnFailure // show captured logs when a test fails
class UserServiceTest {
private static final Logger log = LoggerFactory.getLogger(UserServiceTest.class);
@InjectLogCaptor
LogCaptor logCaptor;
@Test
void info_with_mdc() {
MDC.put("userId", "alice");
log.info("User {} logged in", "alice");
assertThat(logCaptor)
.atLevel(Level.INFO)
.withMdcEntry("userId", "alice")
.single()
.hasFormattedMessageContaining("alice")
.hasNoThrowable();
}
@Test
void error_with_exception() {
RuntimeException ex = new RuntimeException("boom");
log.error("Processing failed", ex);
assertThat(logCaptor)
.atLevel(Level.ERROR)
.single()
.hasFormattedMessageContaining("Processing failed")
.hasThrowable(RuntimeException.class)
.hasThrowableWithMessage("boom");
}
@Test
@EchoLogs(minimumLevel = Level.DEBUG) // print logs to console for this test only
void debug_trace_scenario() {
log.debug("step 1");
log.debug("step 2");
assertThat(logCaptor)
.atLevel(Level.DEBUG)
.hasSize(2);
}
}- JBoss Log Manager only. The capture handler is a JBoss
ExtHandler; it does not work with Logback or java.util.logging alone. - No concurrent test isolation. The capture handler is attached to the root logger and sees
all log events in the JVM. Tests running concurrently share the same captor; log isolation is
not guaranteed. A warning is printed when
@Execution(CONCURRENT)is detected. rawTemplatenot available via JBoss bridge. When routing SLF4J throughslf4j-jboss-logmanager, bothformattedMessageandrawTemplatecontain the resolved message. The raw template is not preserved by the bridge.- Markers not supported in v1.
LogEntry.markerName()is alwaysnull.