When unit testing a class, we would usually mock or stub any dependencies required. The conventional approach to unit testing if we logged a message would be to create a mock implementation of the logger class. We then verify that the mock logger invoked the logging method.

For Log4j2, we can use a simpler approach of injecting a custom Appender instead of creating the mock logger implementation.

Loggers

The Logger is the central interface in the log4j2 library. Most logging operations are done through this interface. By default, a Root logger is defined and available to you. You can define additional loggers with different log levels and appenders. We can configure Loggers in log4j2.xml :

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Loggers>
       <Logger name="foo" level="ERROR"></Logger>
       <Root level="INFO"></Root>
    </Loggers>
</Configuration>

What are Appenders?

In Log4j2, Appenders are responsible for sending log messages to a specified destination. The log4j2 library comes with different types of appenders by default. Some of the more useful ones include:

  • ConsoleAppender — logs messages to the stdout console
  • FileAppender — logs messages to a file
  • JDBCAppender — logs messages to a database

An appender can be defined for a logger in log42j.xml :

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
   <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
        </Console>        <File name="FooFile" fileName="bar.log">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
        </File>
   </Appenders>   <Loggers>
     <Logger name="foo" level="ERROR">
        <AppenderRef ref="FooFile"/>
     </Logger>
      <Root level="INFO">
         <AppenderRef ref="Console">
      </Root>
   </Loggers>
</Configuration>

The PatternLayout XML element in an Appender defines how the log message should be formatted for output.

Creating a Test Appender

Now that we know what Appenders are and how to define them, we can create our very own Appender for testing.

package com.example.appender

import org.apache.logging.log4j.core.Filter
import org.apache.logging.log4j.core.Layout
import org.apache.logging.log4j.core.LogEvent
import org.apache.logging.log4j.core.appender.AbstractAppender
import org.apache.logging.log4j.core.config.Property
import org.apache.logging.log4j.core.config.plugins.Plugin
import org.apache.logging.log4j.core.config.plugins.PluginAttribute
import org.apache.logging.log4j.core.config.plugins.PluginElement
import org.apache.logging.log4j.core.config.plugins.PluginFactory
import java.io.Serializable

@Plugin(name = "TestAppender", category = "Core", elementType = "appender", printObject = false)
class TestAppender
private constructor(name: String, filter: Filter?, layout: Layout<out Serializable>) :
    AbstractAppender(name, filter, layout, true, Property.EMPTY_ARRAY) {
    private val messages: MutableList<LogEvent> = ArrayList()

    override fun append(event: LogEvent) {
        messages.add(event)
    }

    fun getMessages(): MutableList<LogEvent> {
        return messages
    }

    fun clearMessages() {
        messages.clear()
    }

    companion object {
        @PluginFactory
        @JvmStatic
        fun createAppender(
            @PluginAttribute("name") name: String,
            @PluginElement("Layout") layout: Layout<out Serializable>,
            @PluginElement("Filter") filter: Filter?
        ): TestAppender {
            return TestAppender(name, filter, layout)
        }
    }
}

This TestAppender can be injected into our tests to test the logging behavior by defining it in a log4j2-test.xml configuration file.

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Properties>
        <Property name="LOG_PATTERN">
            %d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n
        </Property>
    </Properties>

    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="${LOG_PATTERN}" />
        </Console>

        <TestAppender name="TestAppender">
            <PatternLayout pattern="${LOG_PATTERN}" />
        </TestAppender>
    </Appenders>
    <Loggers>
        <Root level="info" additivity="false">
            <AppenderRef ref="Console" />
            <AppenderRef ref="TestAppender" level="error" />
        </Root>
    </Loggers>
</Configuration>

Using the Test Appender

Consider this Foo class:

package com.example

import mu.KotlinLogging

class Foo {
    private val logger = KotlinLogging.logger {}

    fun bar() {
        logger.error("this is a test log message")
    }
}

We can use the TestAppender that we have created previously to write a unit test for the bar function in this class. Our test will be asserting that the logger wrote one message to the log when calling the bar function.

Let’s start by setting up the test (using JUnit5):

internal class FooTest {
    private val foo = Foo()
    private lateinit var testAppender: TestAppender
    private lateinit var logConfig: Configuration    @BeforeEach
    private fun setUp() {
        val ctx = LogManager.getContext(false) as LoggerContext
        logConfig = ctx.configuration
        // no need to output test log data to stdout
        logConfig.rootLogger.removeAppender("Console")
        testAppender = logConfig.appenders["TestAppender"] as TestAppender
    }
}

The setUp function will run before each test case that we will be writing. As part of the setup:

  1. We remove the Console appender to avoid outputting test data to the system console.
  2. We store the TestAppender in a variable so that we can use it for assertions later on.

Next, let’s write the test case:

@Test
fun `bar should add a log message`() {
    foo.bar()
    assertEquals(1, testAppender.getMessages().size)
}

We invoke the bar function on the Foo instance under test. We then check that TestAppender received one message.

Finally, we need to clean up to avoid polluting the state for other tests:

@AfterEach
private fun teardown() {
    testAppender.clearMessages()
    logConfig.addAppender(logConfig.getAppender("Console"))
}

Full Unit Test

The unit test code in full:

package com.example

import com.example.appender.TestAppender
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.core.LoggerContext
import org.apache.logging.log4j.core.config.Configuration
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

internal class FooTest {
    private val foo = Foo()
    private lateinit var testAppender: TestAppender
    private lateinit var logConfig: Configuration

    @BeforeEach
    private fun setUp() {
        val ctx = LogManager.getContext(false) as LoggerContext
        logConfig = ctx.configuration
        // no need to output test log data to stdout
        logConfig.rootLogger.removeAppender("Console")
        testAppender = logConfig.appenders["TestAppender"] as TestAppender
    }

    @Test
    fun `bar should add a log message`() {
        foo.bar()
        assertEquals(1, testAppender.getMessages().size)
    }

    @AfterEach
    private fun teardown() {
        testAppender.clearMessages()
        logConfig.addAppender(logConfig.getAppender("Console"))
    }
}

Summary

To unit test the logging behavior of a class that is utilizing a Log4j2 logger, we can:

  1. Add a custom Appender to the logger
  2. Run the test
  3. Assert that the desired behavior happened by utilizing the custom Appender

I hope you got some useful pointers from this article. If you are interested in more articles about tech, be sure to subscribe to get notified whenever I publish a new article.