什么是测试SLF4J日志信息的最佳方法?

78

我正在使用slf4j,想要对我的代码进行单元测试,以确保在特定条件下生成warn/error日志消息。我希望这些测试是严格的单元测试,因此我不想从文件中调用日志配置来测试日志消息是否生成。我使用的模拟框架是Mockito。


1
由于SLF4J只是其他日志实现的“门面”,因此您不能仅通过它本身进行单元测试,还必须指定您正在使用的实现。 - darioo
2
@darioo - 不是这样的。我可以在我的类中添加一个setter方法来传递测试中的日志记录器,然后传入一个模拟的Logger实例并验证是否进行了适当的日志调用。我只是希望能够得到比添加一个专门用于测试的set方法更优雅的解决方案,并使我的Logger实例非final。 - Javid Jamae
顺便提一下,“Growing Object Oriented Software”这本通常很好的书有一章关于日志记录的单元测试。虽然它并不完全令人信服,但它肯定经过深思熟虑,并值得一读(http://www.amazon.co.uk/Growing-Object-Oriented-Software-Guided-Signature/dp/0321503627/ref=sr_1_1?ie=UTF8&s=books&qid=1294688741&sr=8-1)。 - skaffman
嗨,我发现以下文章 https://mincong.io/2020/02/02/logback-test-logging-event/ 对你的问题非常有帮助。 - adelinor
17个回答

28
创建一个测试规则:
    import ch.qos.logback.classic.Logger;
    import ch.qos.logback.classic.spi.ILoggingEvent;
    import ch.qos.logback.core.read.ListAppender;
    import org.junit.rules.TestRule;
    import org.junit.runner.Description;
    import org.junit.runners.model.Statement;
    import org.slf4j.LoggerFactory;
    
    import java.util.List;
    import java.util.stream.Collectors;
    
    public class LoggerRule implements TestRule {
    
      private final ListAppender<ILoggingEvent> listAppender = new ListAppender<>();
      private final Logger logger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
    
      @Override
      public Statement apply(Statement base, Description description) {
        return new Statement() {
          @Override
          public void evaluate() throws Throwable {
            setup();
            base.evaluate();
            teardown();
          }
        };
      }
    
      private void setup() {
        logger.addAppender(listAppender);
        listAppender.start();
      }
    
      private void teardown() {
        listAppender.stop();
        listAppender.list.clear();
        logger.detachAppender(listAppender);
      }
    
      public List<String> getMessages() {
        return listAppender.list.stream().map(e -> e.getMessage()).collect(Collectors.toList());
      }
    
      public List<String> getFormattedMessages() {
        return listAppender.list.stream().map(e -> e.getFormattedMessage()).collect(Collectors.toList());
      }
    
    }

然后使用它:

    @Rule
    public final LoggerRule loggerRule = new LoggerRule();
    
    @Test
    public void yourTest() {
        // ...
        assertThat(loggerRule.getFormattedMessages().size()).isEqualTo(2);
    }




----- JUnit 5扩展Oct 2021 -----

LogCapture:

public class LogCapture {

  private ListAppender<ILoggingEvent> listAppender = new ListAppender<>();

  LogCapture() {
  }

  public String getFirstFormattedMessage() {
    return getFormattedMessageAt(0);
  }

  public String getLastFormattedMessage() {
    return getFormattedMessageAt(listAppender.list.size() - 1);
  }

  public String getFormattedMessageAt(int index) {
    return getLoggingEventAt(index).getFormattedMessage();
  }

  public LoggingEvent getLoggingEvent() {
    return getLoggingEventAt(0);
  }

  public LoggingEvent getLoggingEventAt(int index) {
    return (LoggingEvent) listAppender.list.get(index);
  }

  public List<LoggingEvent> getLoggingEvents() {
    return listAppender.list.stream().map(e -> (LoggingEvent) e).collect(Collectors.toList());
  }

  public void setLogFilter(Level logLevel) {
    listAppender.clearAllFilters();
    listAppender.addFilter(buildLevelFilter(logLevel));
  }

  public void clear() {
    listAppender.list.clear();
  }

  void start() {
    setLogFilter(Level.INFO);
    listAppender.start();
  }

  void stop() {
    if (listAppender == null) {
      return;
    }

    listAppender.stop();
    listAppender.list.clear();
    listAppender = null;
  }

  ListAppender<ILoggingEvent> getListAppender() {
    return listAppender;
  }

  private Filter<ILoggingEvent> buildLevelFilter(Level logLevel) {
    LevelFilter levelFilter = new LevelFilter();
    levelFilter.setLevel(logLevel);
    levelFilter.setOnMismatch(FilterReply.DENY);
    levelFilter.start();

    return levelFilter;
  }

}

日志捕获扩展:

public class LogCaptureExtension implements ParameterResolver, AfterTestExecutionCallback {

  private Logger logger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);

  private LogCapture logCapture;

  @Override
  public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
    return parameterContext.getParameter().getType() == LogCapture.class;
  }

  @Override
  public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
    logCapture = new LogCapture();

    setup();

    return logCapture;
  }

  @Override
  public void afterTestExecution(ExtensionContext context) {
    teardown();
  }

  private void setup() {
    logger.addAppender(logCapture.getListAppender());
    logCapture.start();
  }

  private void teardown() {
    if (logCapture == null || logger == null) {
      return;
    }

    logger.detachAndStopAllAppenders();
    logCapture.stop();
  }

}

然后使用它:

@ExtendWith(LogCaptureExtension.class)
public class SomeTest {

  @Test
  public void sometest(LogCapture logCapture)  {
    // do test here

    assertThat(logCapture.getLoggingEvents()).isEmpty();
  }

  // ...
}

这是一个聪明的解决方案! - Adil Karaöz
这是一个非常酷的通用解决方案。在使用slf4j-test方法时,我遇到了依赖狱,不得不在数百个不同的地方排除logback-classic,但这种方法优雅地解决了问题,而无需采用反射或更改生产代码。 - Benny Bottema
1
有没有JUnit5的等效物? - Akshay
有用且可用的解决方案!感谢分享! - Sankalpa Wijewickrama
你为什么要将ILoggingEvent转换成LoggingEvent?在我的使用场景中,我不需要LoggingEvent中的setter方法,只需要getter方法就足够了。 - waXve
显示剩余2条评论

21

如果想要在不依赖于特定实现(如log4j)的情况下测试slf4j,可以按照SLF4J FAQ中所描述的方法提供自己的slf4j日志实现。您的实现可以记录已记录的消息,然后由单元测试进行验证。

slf4j-test软件包正是这样做的。它是一个内存中的slf4j日志实现,提供了检索记录消息的方法。


完整的示例使用lidalia的slf4j-test包可以在此处找到:https://github.com/jaegertracing/jaeger-client-java/pull/378/files。不可否认,他们的文档也非常出色。 - Debosmit Ray
4
由于"为即将到来的 Jigsaw(Java 9)做计划,slf4j-api 1.8.x 及更高版本使用 ServiceLoader 机制。SLF4J 的早期版本依赖于静态绑定机制,该机制不再受 slf4j-api 支持",因此无法再使用 slf4j-api 版本1.8或更高版本。(请参见https://www.slf4j.org/codes.html)。 - dzieciou

15

我认为您可以通过自定义appender来解决您的问题。创建一个实现org.apache.log4j.Appender的测试appender,并在log4j.properties中设置您的appender并在执行测试用例时加载它。

如果从那个appender回调到测试框架,则可以检查记录的消息。


47
如果您能提供一些代码示例,我们将不胜感激。 - kenshinji
3
但是看起来这个例子对于slf4j无效! - Shilan

14

为了JUnit 5,在扩展中实现andrew-feng创建一个测试规则中提供的解决方案:

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.slf4j.LoggerFactory;

import java.util.List;
import java.util.stream.Collectors;

public class LoggerExtension implements BeforeEachCallback, AfterEachCallback {

    private final ListAppender<ILoggingEvent> listAppender = new ListAppender<>();
    private final Logger logger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);

    @Override
    public void afterEach(ExtensionContext extensionContext) throws Exception {
        listAppender.stop();
        listAppender.list.clear();
        logger.detachAppender(listAppender);
    }

    @Override
    public void beforeEach(ExtensionContext extensionContext) throws Exception {
        logger.addAppender(listAppender);
        listAppender.start();
    }

    public List<String> getMessages() {
        return listAppender.list.stream().map(e -> e.getMessage()).collect(Collectors.toList());
    }

    public List<String> getFormattedMessages() {
        return listAppender.list.stream().map(e -> e.getFormattedMessage()).collect(Collectors.toList());
    }

}

然后使用它:

@RegisterExtension
public LoggerExtension loggerExtension = new LoggerExtension();

@Test
public void yourTest() {
    // ...
    assertThat(loggerExtension.getFormattedMessages().size()).isEqualTo(2);
}

3
您可以在测试的开头使用以下语句: Logger logger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); ListAppender<ILoggingEvent> listAppender = new ListAppender<>(); logger.addAppender(listAppender); listAppender.start();然后在测试结束时断言: List<ILoggingEvent> logsList = listAppender.list; assertEquals(...,logsList.get(1))。(说明:这段代码是关于日志记录的,在测试开始时,它会创建一个“Logger”对象,并将其附加到一个“ListAppender”对象上,使得测试期间所有的日志输出都能被捕获并存储下来。在测试结束时,使用断言来检查日志输出是否符合预期。) - carlos palma

10
一种更好的SLF4J测试实现,适用于具有并发测试执行环境的情况,可以参考https://github.com/portingle/slf4jtesting
我曾在几次SLF4J日志测试讨论中发表过意见,当涉及到并发测试执行时,现有的测试方法存在限制。
我决定付诸实践,并将代码放在了那个git仓库中。

1
希望很快会有第三个答案出现,来自于一个拥有更好的SLF4j测试实现的人... ;) - Adam
2
在我看来,这不是一个好的工具。它只有在构造函数内创建Logger时才能正常工作。但是谁会这样做呢?Logger通常是一个常量:private static final Logger LOGGER = LoggerFactory.getLogger(Example.class)。该工具无法处理常量记录器,因此无法处理正常的使用情况。 - Sven Döring
你可以通过重载构造函数来“解决”这个问题(下一个评论将是Java示例代码)。 - granadaCoder
包 com.me.hello;import org.slf4j.Logger; import org.slf4j.LoggerFactory;import javax.inject.Inject;public final class MyThing {public static final String ERROR_MSG_LOGGER_IS_NULL = "Logger is null"; private Logger logger; @Inject public MyThing() { this(LoggerFactory.getLogger(MyThing.class)); } public MyThing(final Logger lgr) { if (null == lgr) { throw new IllegalArgumentException(ERROR_MSG_LOGGER_IS_NULL); } this.logger = lgr; }} - granadaCoder
这是这类事情的一个非常常见的模式,不仅仅是记录器,而是任何你想要一个最终结果但又不想被迫为每个东西使用构造函数注入的地方。这是我需要时经常使用的一种模式。 - johnlon

5

与其嘲弄SLF4J,不如将你需要测试的重要日志调用放在它们自己的方法中,这样你就可以更轻松地进行模拟。

如果你真的想要模拟SLF4J,我敢打赌你可以创建自己的提供者来允许你从SLF4J方面提供一个伪造记录器,而不是将其注入到你的服务对象中。


4
使用slf4j-test可以消除上述讨论的大量解决方法。 pom.xml
 <dependency>
       <groupId>uk.org.lidalia</groupId>
       <artifactId>slf4j-test</artifactId>
       <version>1.2.0</version>
 </dependency>

样例类

@Slf4j
public class SampleClass {

    public void logDetails(){
        log.info("Logging");
    }
}

测试类

import org.junit.Test;
import uk.org.lidalia.slf4jtest.TestLogger;
import uk.org.lidalia.slf4jtest.TestLoggerFactory;

import static java.util.Arrays.asList;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;
import static uk.org.lidalia.slf4jtest.LoggingEvent.info;

public class SampleClassTest {

    TestLogger logger = TestLoggerFactory.getTestLogger(SampleClass.class);

    @Test
    public void testLogging(){
        SampleClass sampleClass = new SampleClass();
        //Invoke slf4j logger
        sampleClass.logDetails();

        assertThat(logger.getLoggingEvents(), is(asList(info("Logging"))));

    }

}

请参考http://projects.lidalia.org.uk/slf4j-test/了解更多详情。


为了使其工作,用户应添加以下行: ImmutableList arguments = loggingEvents.get(0).getArguments(); String actual = MessageFormatter.arrayFormat(loggingEvents.get(0).getMessage(), arguments.toArray()).getMessage(); ``` - Ashish Sharma
2

和@Zsolt类似,您可以模拟log4j Appender并将其设置在Logger上,然后验证对Appender.doAppend()的调用。这样可以在不修改真实代码的情况下进行测试。


1
我不确定为什么这个回答被投票否决。这正是Mockito的用途,即模拟难以测试的协作者。这个回答已经存活了将近6年而没有被否决。 - Kevin Welker

2
这个解决方案已经在这个 Groovy 答案和这个评论中提到过,但由于我没有看到它本身作为答案,所以将其添加到这里作为社区 wiki 答案。
所以,使用 logback listappender 的 JUnit5 解决方案:
import static org.assertj.core.api.Assertions.assertThat;

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;

public class LoggingTest {
  private final ClassToTest sut = new ClassToTest();

  private ListAppender<ILoggingEvent> listAppender;

  @BeforeEach
  void init() {
    final var log = (Logger) LoggerFactory.getLogger(ClassToTest.class);

    listAppender = new ListAppender<>();
    listAppender.start();

    log.addAppender(listAppender);
  }

  @Test
  public void testLogging() {
    sut.doSomethingThatLogs()
    String message = listAppender.list.get(0).getFormattedMessage();
    assertThat(message).contains("this message should be logged");
  }
}

2
你可以尝试使用另一个库来支持易于模拟的slf4j日志记录器 - slf4j-mock,你的代码可能会像这样看起来:
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;
import org.slf4j.Logger;

@RunWith(MockitoJUnitRunner.class)
public class JUnit4ExampleTest {

    private static final String INFO_TEST_MESSAGE = "info log test message from JUnit4";

    @Mock
    Logger logger;

    @InjectMocks
    Example sut;

    @Test
    public void logInfoShouldBeLogged() {

        // when
        sut.methodWithLogInfo(INFO_TEST_MESSAGE);

        // then
        Mockito.verify(logger).info(INFO_TEST_MESSAGE);
        Mockito.verifyNoMoreInteractions(logger);
    }
}

正如您所见,测试代码中不需要任何特殊步骤。您只需在项目中添加库的依赖项。

更多示例和说明请参见:

https://www.simplify4u.org/slf4j-mock/


SLF4J: 实际绑定类型为[ch.qos.logback.classic.util.ContextSelectorStaticBinder]java.lang.ClassCastException: ch.qos.logback.classic.Logger无法转换为org.simplify4u.slf4jmock.ProxyMock - Tablo_Jhin

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接