1. Introduction
In this tutorial, we’ll look at how we can cover generated logs in JUnit testing.
We’ll use the slf4j-api and the logback implementation and create a custom appender that we can use for log assertion.
2. Maven Dependencies
Before we begin, let’s add the logback dependency. As it natively implements the slf4j-api, it is automatically downloaded and injected into the project by Maven transitivity:
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>.
<version>1.2.6</version>
</dependency>
AssertJ offers very useful functions when testing, so let’s add its dependency to the project as well:
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.15.0</version>
<scope>test</scope>
</dependency>
3. A Basic Business Function
Now, let’s create an object that will generate logs we can base our tests on.
Our BusinessWorker object will only expose one method. This method will generate a log with the same content for each log level. Although this method isn’t that useful in the real world, it’ll serve well for our testing purposes:
public class BusinessWorker {
private static Logger LOGGER = LoggerFactory.getLogger(BusinessWorker.class);
public void generateLogs(String msg) {
LOGGER.trace(msg);
LOGGER.debug(msg);
LOGGER.info(msg);
LOGGER.warn(msg);
LOGGER.error(msg);
}
}
4. Testing the Logs
We want to generate logs, so let’s create a logback.xml file in the src/test/resources folder. Let’s keep it as simple as possible and redirect all logs to a CONSOLE appender:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n
</Pattern>
</layout>
</appender>
<root level="error">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
4.1. MemoryAppender
Now, let’s create a custom appender that keeps logs in memory. We’ll extend the ListAppender
public class MemoryAppender extends ListAppender<ILoggingEvent> {
public void reset() {
this.list.clear();
}
public boolean contains(String string, Level level) {
return this.list.stream()
.anyMatch(event -> event.toString().contains(string)
&& event.getLevel().equals(level));
}
public int countEventsForLogger(String loggerName) {
return (int) this.list.stream()
.filter(event -> event.getLoggerName().contains(loggerName))
.count();
}
public List<ILoggingEvent> search(String string) {
return this.list.stream()
.filter(event -> event.toString().contains(string))
.collect(Collectors.toList());
}
public List<ILoggingEvent> search(String string, Level level) {
return this.list.stream()
.filter(event -> event.toString().contains(string)
&& event.getLevel().equals(level))
.collect(Collectors.toList());
}
public int getSize() {
return this.list.size();
}
public List<ILoggingEvent> getLoggedEvents() {
return Collections.unmodifiableList(this.list);
}
}
The MemoryAppender class handles a List that is automatically populated by the logging system.
It exposes a variety of methods in order to cover a wide range of test purposes:
- reset() – clears the list
- contains(msg, level) – returns true only if the list contains an ILoggingEvent matching the specified content and severity level
- countEventForLoggers(loggerName) – returns the number of ILoggingEvent generated by named logger
- search(msg) – returns a List of ILoggingEvent matching the specific content
- search(msg, level) – returns a List of ILoggingEvent matching the specified content and severity level
- getSize() – returns the number of ILoggingEvents
- getLoggedEvents() – returns an unmodifiable view of the ILoggingEvent elements
4.2. Unit Test
Next, let’s create a JUnit test for our business worker.
We’ll declare our MemoryAppender as a field and programmatically inject it into the log system. Then, we’ll start the appender.
For our tests, we’ll set the level to DEBUG:
@Before
public void setup() {
Logger logger = (Logger) LoggerFactory.getLogger(LOGGER_NAME);
memoryAppender = new MemoryAppender();
memoryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory());
logger.setLevel(Level.DEBUG);
logger.addAppender(memoryAppender);
memoryAppender.start();
}
Now we can create a simple test where we instantiate our BusinessWorker class and call the generateLogs method. We can then make assertions on the logs that it generates:
@Test
public void test() {
BusinessWorker worker = new BusinessWorker();
worker.generateLogs(MSG);
assertThat(memoryAppender.countEventsForLogger(LOGGER_NAME)).isEqualTo(4);
assertThat(memoryAppender.search(MSG, Level.INFO).size()).isEqualTo(1);
assertThat(memoryAppender.contains(MSG, Level.TRACE)).isFalse();
}
This test uses three features of the MemoryAppender:
- Four logs have been generated — one entry per severity should be present, with the trace level filtered
- Only one log entry with the content message with the level severity of INFO
- No log entry is present with content message and severity TRACE
If we plan to use the same instance of this class inside the same test class when generating a lot of logs, the memory usage will creep up. We can invoke the MemoryAppender.clear()* method before each test to free memory and avoid *OutOfMemoryException.
In this example, we’ve reduced the scope of the retained logs to the LOGGER_NAME package, which we defined as “com.baeldung.junit.log“. We could potentially retain all logs with LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME), but we should avoid this whenever possible as it can consume a lot of memory.
5. Conclusion
With this tutorial, we’ve demonstrated how to cover log generation in our unit tests.
As always, the code can be found over on GitHub.