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>importstaticio.github.logassert.assertj.LogCaptorAssertions.assertThat;
importio.github.logassert.core.LogCaptor;
importio.github.logassert.junit5.*;
importorg.junit.jupiter.api.*;
importorg.junit.jupiter.api.extension.*;
importorg.slf4j.event.Level;
@ExtendWith(LogCaptorExtension.class)
classOrderServiceTest {
@InjectLogCaptorLogCaptorlogCaptor; // auto-injected and auto-cleared before each test@Testvoidpayment_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:
importio.quarkus.test.junit.QuarkusTest;
importio.github.logassert.junit5.*;
importorg.junit.jupiter.api.extension.RegisterExtension;
@QuarkusTestclassPaymentResourceTest {
@RegisterExtension// MUST be staticstaticLogCaptorExtensionlogExt = LogCaptorExtension.create();
@InjectLogCaptorLogCaptorlogCaptor;
@Testvoidcaptures_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.
@InjectLogCaptorLogCaptorcaptor;The captor can also be received as a method parameter:
@Testvoidexample(LogCaptorcaptor) { ... }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 failsclassMyTest { ... }Can also be placed on a single method:
@Test@PrintLogsOnFailure// dump logs only when this test failsvoidnoisy_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@Testvoiddebug_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 testclassStrictServiceTest { ... }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.
importstaticio.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 listList<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 newLogsAssert 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 viewlogCaptor.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. |
importstaticio.github.logassert.assertj.LogCaptorAssertions.assertThat;
importio.github.logassert.core.LogCaptor;
importio.github.logassert.junit5.*;
importorg.junit.jupiter.api.*;
importorg.junit.jupiter.api.extension.*;
importorg.slf4j.Logger;
importorg.slf4j.LoggerFactory;
importorg.slf4j.MDC;
importorg.slf4j.event.Level;
@ExtendWith(LogCaptorExtension.class)
@FailOnUncheckedError// unexpected ERRORs will fail the test@PrintLogsOnFailure// show captured logs when a test failsclassUserServiceTest {
privatestaticfinalLoggerlog = LoggerFactory.getLogger(UserServiceTest.class);
@InjectLogCaptorLogCaptorlogCaptor;
@Testvoidinfo_with_mdc() {
MDC.put("userId", "alice");
log.info("User {} logged in", "alice");
assertThat(logCaptor)
.atLevel(Level.INFO)
.withMdcEntry("userId", "alice")
.single()
.hasFormattedMessageContaining("alice")
.hasNoThrowable();
}
@Testvoiderror_with_exception() {
RuntimeExceptionex = newRuntimeException("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 onlyvoiddebug_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.