SLF4J
Logback
JUnit
Logging
Java Testing

How to intercept SLF4J with logback logging via a JUnit test?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Sometimes a unit test should verify that code logs the right message, not just that it returns the right value. That happens when logging is part of the behavior contract, such as warning on invalid input, recording a correlation ID, or suppressing noisy stack traces. With SLF4J backed by Logback, the cleanest way to test logging is usually to attach a temporary in-memory appender and assert on the captured events.

Why You Test the Backend, Not SLF4J Alone

SLF4J is only a logging facade. It defines the API your code calls, but it does not store log events itself. The actual capture happens in the backend, which in this case is Logback.

That is why a JUnit test usually works with Logback classes such as:

  • 'ch.qos.logback.classic.Logger'
  • 'ListAppender'
  • 'ILoggingEvent'

The test does not replace SLF4J. It temporarily observes the concrete Logback logger used by the class under test.

A Small Example Class

Suppose a service logs a warning when someone tries to reserve an invalid quantity:

java
1package example;
2
3import org.slf4j.Logger;
4import org.slf4j.LoggerFactory;
5
6public class InventoryService {
7    private static final Logger log =
8            LoggerFactory.getLogger(InventoryService.class);
9
10    public boolean reserve(String sku, int quantity) {
11        if (quantity <= 0) {
12            log.warn("Refusing reservation for sku {} with quantity {}", sku, quantity);
13            return false;
14        }
15
16        log.info("Reserved {} units for {}", quantity, sku);
17        return true;
18    }
19}

A useful test can assert that invalid input produces a WARN event with the expected rendered message.

Capture Logs with ListAppender

Here is a JUnit 5 test that intercepts the logging output:

java
1package example;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4import static org.junit.jupiter.api.Assertions.assertFalse;
5
6import ch.qos.logback.classic.Level;
7import ch.qos.logback.classic.Logger;
8import ch.qos.logback.classic.spi.ILoggingEvent;
9import ch.qos.logback.core.read.ListAppender;
10import java.util.List;
11import org.junit.jupiter.api.AfterEach;
12import org.junit.jupiter.api.BeforeEach;
13import org.junit.jupiter.api.Test;
14import org.slf4j.LoggerFactory;
15
16class InventoryServiceTest {
17    private Logger logger;
18    private ListAppender<ILoggingEvent> appender;
19
20    @BeforeEach
21    void setUp() {
22        logger = (Logger) LoggerFactory.getLogger(InventoryService.class);
23        appender = new ListAppender<>();
24        appender.start();
25        logger.addAppender(appender);
26    }
27
28    @AfterEach
29    void tearDown() {
30        logger.detachAppender(appender);
31        appender.stop();
32    }
33
34    @Test
35    void logsWarningForInvalidQuantity() {
36        InventoryService service = new InventoryService();
37
38        boolean reserved = service.reserve("ABC-123", 0);
39
40        assertFalse(reserved);
41
42        List<ILoggingEvent> events = appender.list;
43        assertEquals(1, events.size());
44        assertEquals(Level.WARN, events.get(0).getLevel());
45        assertEquals(
46                "Refusing reservation for sku ABC-123 with quantity 0",
47                events.get(0).getFormattedMessage()
48        );
49    }
50}

This is usually enough for application-level logging tests.

What to Assert On

The captured event gives you several useful angles:

  • 'getLevel() for INFO, WARN, or ERROR'
  • 'getFormattedMessage() for the final rendered message'
  • 'getThrowableProxy() for attached exceptions'
  • 'getMDCPropertyMap() for request IDs or other contextual data'

For placeholder-based logging, prefer getFormattedMessage() over getMessage(). The raw message still contains the template with placeholder markers, while the formatted message shows what an operator would actually read in logs.

Testing MDC and Structured Context

If your application depends on MDC values, assert on those too:

java
1import org.slf4j.MDC;
2
3MDC.put("requestId", "req-42");
4logger.info("Processing request");
5
6String requestId = appender.list.get(0).getMDCPropertyMap().get("requestId");
7assertEquals("req-42", requestId);
8
9MDC.clear();

This is useful when you need confidence that log enrichment survives refactors.

Keep the Logger State Isolated

Loggers are shared state inside the JVM, so cleanup matters. Attach the appender in @BeforeEach, detach it in @AfterEach, and restore any level changes you make during the test.

If you raise or lower the level, save the old one first:

java
1Level originalLevel = logger.getLevel();
2logger.setLevel(Level.DEBUG);
3
4try {
5    // assertions
6} finally {
7    logger.setLevel(originalLevel);
8}

Without cleanup, one logging test can leak configuration into the next one and create confusing failures.

When This Style of Test Is Worth It

Do not test every log line in the codebase. That becomes brittle quickly. Logging assertions are most useful when the log output matters to system behavior or operational support, for example:

  • security audit events
  • warnings for rejected input
  • messages consumed by alerting rules
  • correlation or trace identifiers required in production

That keeps the tests meaningful instead of turning them into low-value snapshots of implementation detail.

Common Pitfalls

One common mistake is attaching the appender to the wrong logger name. If the production class logs through InventoryService.class, but the test captures some other logger, the list stays empty and the test gives a misleading failure.

Another mistake is forgetting appender.start(). A ListAppender that is never started will not capture anything.

Developers also often assert on getMessage() when the code uses placeholder arguments. That checks the template instead of the rendered message.

Finally, always detach the appender after the test. Logging infrastructure is shared, and leaked appenders can make later tests capture unrelated events.

Summary

  • With SLF4J and Logback, test logging by attaching a temporary ListAppender.
  • Capture the exact logger used by the class under test.
  • Prefer getFormattedMessage() when asserting placeholder-based log statements.
  • Clean up appenders and any temporary level changes after each test.
  • Log assertions are most valuable when logging is part of the observable contract, not just incidental noise.

Course illustration
Course illustration

All Rights Reserved.