如何通过JUnit测试拦截SLF4J(使用logback)日志记录?

145

有没有办法通过JUnit测试用例截取日志记录(SLF4J + logback),并获取InputStream(或其他可读的内容)?

12个回答

144

Slf4j API没有提供这样的方法,但Logback提供了一个简单的解决方案。

您可以使用 ListAppender :一个白盒子 logback appender,其中日志条目添加在一个public List字段中,我们可以使用它来进行断言。

这是一个简单的例子。

Foo类:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Foo {

    static final Logger LOGGER = LoggerFactory.getLogger(Foo .class);

    public void doThat() {
        logger.info("start");
        //...
        logger.info("finish");
    }
}

FooTest类:

import org.slf4j.LoggerFactory;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;

public class FooTest {

    @Test
    void doThat() throws Exception {
        // get Logback Logger 
        Logger fooLogger = (Logger) LoggerFactory.getLogger(Foo.class);

        // create and start a ListAppender
        ListAppender<ILoggingEvent> listAppender = new ListAppender<>();
        listAppender.start();

        // add the appender to the logger
        fooLogger.addAppender(listAppender);

        // call method under test
        Foo foo = new Foo();
        foo.doThat();

        // JUnit assertions
        List<ILoggingEvent> logsList = listAppender.list;
        assertEquals("start", logsList.get(0)
                                      .getMessage());
        assertEquals(Level.INFO, logsList.get(0)
                                         .getLevel());

        assertEquals("finish", logsList.get(1)
                                       .getMessage());
        assertEquals(Level.INFO, logsList.get(1)
                                         .getLevel());
    }
}

你也可以使用Matcher/断言库,例如AssertJ或Hamcrest。

使用AssertJ的话,代码如下:

import org.assertj.core.api.Assertions;

Assertions.assertThat(listAppender.list)
          .extracting(ILoggingEvent::getFormattedMessage, ILoggingEvent::getLevel)
          .containsExactly(Tuple.tuple("start", Level.INFO), Tuple.tuple("finish", Level.INFO));

2
非常感谢!这正是我在寻找的! - Oli
8
使用 org.slf4j.LoggerFactoryLoggerFactorych.qos.logback.classic.LoggerLogger,当执行 Logger fooLogger = (Logger) LoggerFactory.getLogger(Foo.class); 时会出现 ClassCastException 异常。请帮我翻译这句话。 - hiren
1
@Hiren 如果你使用JUnit 5,请确保不要意外导入org.junit.platform.commons.logging.LoggerFactory,这就是我遇到的问题。 - Denis Washington
7
需要注意的是,如果您的日志包含参数值,则应使用ILoggingEvent::getFormattedMessage而不是ILoggingEvent::getMessage。否则,由于值缺失,您的断言将失败。 - Robert Mason
5
如果您正在使用 SLF4J,那么这个解决方案最终会引发 SLF4J: Class path contains multiple SLF4J bindings. 警告,因为您同时拥有 SLF4J 和 logback.classic。 - Ghilteras
显示剩余5条评论

55
你可以创建一个自定义的appender。
public class TestAppender extends AppenderBase<LoggingEvent> {
    static List<LoggingEvent> events = new ArrayList<>();
    
    @Override
    protected void append(LoggingEvent e) {
        events.add(e);
    }
}

并配置logback-test.xml以使用它。现在我们可以检查来自测试的日志记录事件:

@Test
public void test() {
    ...
    Assert.assertEquals(1, TestAppender.events.size());
    ...
}

注意:如果您没有收到任何输出,请使用ILoggingEvent - 请参阅评论部分以了解原因。


17
注意,如果您正在使用logback classic + slf4j,则需要使用 ILoggingEvent 而不是 LoggingEvent。 这对我有效。 - etech
7
请问您能否展示如何配置logback-test.xml? - hipokito
2
我假设你需要在每次测试执行后清除“events” 。 - Andrii Karaivanskyi
3
你可以在sample0.xml 中使用此处提到的方式。别忘了将appender更改为你的实现。 - coding_idiot
@EvgeniyDorofeev,能帮我解决这个问题吗?https://dev59.com/majka4cB1Zd3GeqPCa7z - Bhavya Arora
显示剩余5条评论

47

使用JUnit5

private ListAppender<ILoggingEvent> logWatcher;

@BeforeEach
void setup() {
  logWatcher = new ListAppender<>();
  logWatcher.start();
  ((Logger) LoggerFactory.getLogger(MyClass.class)).addAppender(logWatcher);
}

注意:MyClass.class应该替换为您期望从中获取日志输出的产品类。

用法:(AssertJ示例)


@Test
void myMethod_logs2Messages() {

  ...
  int logSize = logWatcher.list.size();
  assertThat(logWatcher.list.get(logSize - 2).getFormattedMessage()).contains("EXPECTED MSG 1");
  assertThat(logWatcher.list.get(logSize - 1).getFormattedMessage()).contains("EXPECTED MSG 2");
}

销毁:

为更好的性能,建议使用分离(detach):

@AfterEach
void teardown() {
  ((Logger) LoggerFactory.getLogger(MyClass.class)).detachAndStopAllAppenders();
}

引入:

import org.slf4j.LoggerFactory;
import ch.qos.logback.core.read.ListAppender;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.Logger;

感谢@davidxxx的回答。有关import ch.qos.logback...的详细信息,请查看https://dev59.com/dF4b5IYBdhLWcg3wdxnG#52229629


终于找到一个简单而有效的解决方案。谢谢! - Gweltaz Niquel
2
看起来很有前途。但是我接着收到了“类路径包含多个SLF4J提供程序”的警告,以及一个“java.lang.ClassCastException: org.slf4j.simple.SimpleLogger无法转换为ch.qos.logback.classic.Logger”的错误。我尝试运行“mvn dependency:tree”来禁用slf4j提供程序,但它失败了。 :-/ - Eric Duminil
我在尝试时遇到了问题。它说logWatcher需要是一个appender。 - undefined
你使用了与答案列表相同的导入吗?import ch.qos.logback.core.read.ListAppender; - undefined

26
你可以使用来自http://projects.lidalia.org.uk/slf4j-test/的slf4j-test。 它将整个logback slf4j实现替换为自己的用于测试的slf4j api实现,并提供一种api来对日志事件进行断言。
例子:
<build>
  <plugins>
    <plugin>
      <artifactId>maven-surefire-plugin</artifactId>
      <configuration>
        <classpathDependencyExcludes>
          <classpathDependencyExcludes>ch.qos.logback:logback-classic</classpathDependencyExcludes>
        </classpathDependencyExcludes>
      </configuration>
    </plugin>
  </plugins>
</build>
public class Slf4jUser {
    
    private static final Logger logger = LoggerFactory.getLogger(Slf4jUser.class);
    
    public void aMethodThatLogs() {
        logger.info("Hello World!");
    }
}
public class Slf4jUserTest {
    
    Slf4jUser slf4jUser = new Slf4jUser();
    TestLogger logger = TestLoggerFactory.getTestLogger(Slf4jUser.class);
    
    @Test
    public void aMethodThatLogsLogsAsExpected() {
        slf4jUser.aMethodThatLogs();
    
        assertThat(logger.getLoggingEvents(), is(asList(info("Hello World!"))));
    }
    
    @After
    public void clearLoggers() {
        TestLoggerFactory.clear();
    }
}

谢谢你提供这个替代方案!看起来非常有用,我将来很可能也会尝试这种方法!不幸的是,我已经接受了另一个同样正确的答案。 - carlspring
可以在此处找到使用lidalia的slf4j-test包的完整示例:https://github.com/jaegertracing/jaeger-client-java/pull/378/files - Debosmit Ray
2
如果您不使用Spring,这个解决方案可以正常工作。但是如果您使用Spring,它会抛出一个找不到类的异常(JoranConfigurator)。 - Jesus H

13

一个简单的解决方案是使用Mockito模拟(例如)appender。

MyClass.java

@Slf4j
class MyClass {
    public void doSomething() {
        log.info("I'm on it!");
    }
}

MyClassTest.java

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.verify;         

@RunWith(MockitoJUnitRunner.class)
public class MyClassTest {    

    @Mock private Appender<ILoggingEvent> mockAppender;
    private MyClass sut = new MyClass();    

    @Before
    public void setUp() {
        Logger logger = (Logger) LoggerFactory.getLogger(MyClass.class.getName());
        logger.addAppender(mockAppender);
    }

    @Test
    public void shouldLogInCaseOfError() {
        sut.doSomething();

        verify(mockAppender).doAppend(ArgumentMatchers.argThat(argument -> {
            assertThat(argument.getMessage(), containsString("I'm on it!"));
            assertThat(argument.getLevel(), is(Level.INFO));
            return true;
        }));

    }

}

注意:我使用断言而不是返回false,因为这样可以使代码和(可能的)错误更容易阅读,但如果您有多个验证,则这种方法不起作用。在那种情况下,您需要返回指示值是否符合预期的boolean


如果我使用lombok.extern.slf4j注释,这个代码是否可行?如果我的类中甚至没有一个logger对象,那么如何mock或spy logger?即log.error仅通过在我的类上提供Slf4j注释来使用。 - ennth
@ennth 这应该可以工作,因为您正在使用静态方法LoggerFactory.getLogger()注入模拟器并添加Appender(mockAppender)。这与使用Lombok创建记录器时的方式相同。 - snovelli
5
同样遇到无法工作的问题。Logger和LoggerFactory类的“imports”是什么?为什么静态导入被列出,而其他导入则没有? - Dirk Schumacher
3
@DirkSchumacher 我也曾经有同样的困惑,以下导入可以正常工作:import org.slf4j.LoggerFactory;import ch.qos.logback.classic.Level;import ch.qos.logback.classic.Logger;import ch.qos.logback.classic.spi.ILoggingEvent;import ch.qos.logback.core.Appender; - Egemen

9
我建议使用一个简单、可重用的间谍实现,可以将其作为JUnit规则包含在测试中:
public final class LogSpy extends ExternalResource {

    private Logger logger;
    private ListAppender<ILoggingEvent> appender;

    @Override
    protected void before() {
        appender = new ListAppender<>();
        logger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); // cast from facade (SLF4J) to implementation class (logback)
        logger.addAppender(appender);
        appender.start();
    }

    @Override
    protected void after() {
        logger.detachAppender(appender);
    }

    public List<ILoggingEvent> getEvents() {
        if (appender == null) {
            throw new UnexpectedTestError("LogSpy needs to be annotated with @Rule");
        }
        return appender.list;
    }
}

在测试中,您需要按以下方式激活间谍:

@Rule
public LogSpy log = new LogSpy();

调用log.getEvents()(或其他自定义方法)检查已记录的事件。


4
为了使这个工作正常,你需要导入 ch.qos.logback.classic.Logger 而不是 org.slf4j.LoggerFactory,否则 addAppender() 方法将无法使用。我花了一段时间才弄清楚这一点。 - Urs Beeli
1
对我来说不起作用。看起来规则没有正确应用:在调试时,我发现before()after()从未被触发,因此附加器从未被创建/附加,导致UnexpectedTestError出现。有什么想法我做错了什么吗?规则需要放置在特定的包中吗?另外,请将导入部分添加到您的答案中,因为某些对象/接口具有模糊的名称。 - Philzen

9

虽然创建自定义的logback appender是一个不错的解决方案,但这只是第一步,最终你会开发/重复发明slf4j-test,如果你再深入一点: spf4j-slf4j-test,或其他我尚未知晓的框架。

最终你需要担心内存中保存的事件数量,当记录错误日志(而不是断言)时失败单元测试,使调试日志在测试失败时可用等等……

免责声明:我是spf4j-slf4j-test的作者,我编写了此后端以便更好地测试spf4j,这是一个寻找如何使用spf4j-slf4j-test的示例的好地方。我实现的主要优点之一是减少我的构建输出(这在Travis中有限制),同时仍然拥有我需要的所有详细信息,当发生故障时。


4

这是一个使用Lambda表达式的替代方法,使日志捕获逻辑在测试中可重用(封装其实现),并且不需要@BeforeEach/@AfterEach(在某些建议的解决方案中,附加器没有被分离,可能会导致内存泄漏)。

被测试的代码:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyService {

    private static final Logger LOG = LoggerFactory.getLogger(MyService.class);


    public void doSomething(String someInput) {
        ...
        LOG.info("processing request with input {}", someInput);
        ...
    }
}

拦截器助手:

package mypackage.util

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.slf4j.LoggerFactory;

import java.util.List;

public class LogInterceptor {

    public static List<ILoggingEvent> interceptLogs(Class<?> klass, Runnable runnable) {
        final Logger logger = (Logger) LoggerFactory.getLogger(klass);
        final ListAppender<ILoggingEvent> listAppender = new ListAppender<>();
        listAppender.start();
        logger.addAppender(listAppender);
        try {
            runnable.run();
            return listAppender.list;
        } finally {
            logger.detachAppender(listAppender);
        }
    }
}

测试套件:


import static mypackage.util.LogInterceptor.interceptLogs;

public class MyServiceTest {

  private MyService myService; 
  ...

  @Test
  void doSomethingLogsLineWithTheGivenInput() {
        List<ILoggingEvent> logs = interceptLogs(
                myService.getClass(),
                () -> myService.doSomething("foo")
        );

        assertThat(logs).isNotEmpty();
        ILoggingEvent logEntry = logs.get(0);
        assertThat(logEntry.getFormattedMessage()).isEqualTo("Processing request with input foo");
        assertThat(logEntry.getLevel()).isEqualTo(Level.INFO);
  }

}


3

在测试如“LOGGER.error(message, exception)”这样的日志行时遇到了问题。

http://projects.lidalia.org.uk/slf4j-test/中描述的解决方案尝试对异常进行断言,但很难(并且在我看来毫无意义)重建堆栈跟踪。

我的解决方法是:

import org.junit.Test;
import org.slf4j.Logger;
import uk.org.lidalia.slf4jext.LoggerFactory;
import uk.org.lidalia.slf4jtest.TestLogger;
import uk.org.lidalia.slf4jtest.TestLoggerFactory;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.groups.Tuple.tuple;
import static uk.org.lidalia.slf4jext.Level.ERROR;
import static uk.org.lidalia.slf4jext.Level.INFO;


public class Slf4jLoggerTest {

    private static final Logger LOGGER = LoggerFactory.getLogger(Slf4jLoggerTest.class);


    private void methodUnderTestInSomeClassInProductionCode() {
        LOGGER.info("info message");
        LOGGER.error("error message");
        LOGGER.error("error message with exception", new RuntimeException("this part is not tested"));
    }





    private static final TestLogger TEST_LOGGER = TestLoggerFactory.getTestLogger(Slf4jLoggerTest.class);

    @Test
    public void testForMethod() throws Exception {
        // when
        methodUnderTestInSomeClassInProductionCode();

        // then
        assertThat(TEST_LOGGER.getLoggingEvents()).extracting("level", "message").contains(
                tuple(INFO, "info message"),
                tuple(ERROR, "error message"),
                tuple(ERROR, "error message with exception")
        );
    }

}

这样做的好处是不需要依赖于Hamcrest匹配器库。


1
我想记录的类上有一个 lombok 的 @Slf4j 注解,我按照以下方式操作。

被测试类

    @Slf4j
    @Service
    public class TesteeService {

       Mono<ResponseEntity<HttpStatus>> sendRequest(RequestDto requestDto) {...logs an ERROR sometimes..}

创建了一个新类

public class MemoryLogAppender extends AppenderBase {

        private final List<LogEvent> list = new ArrayList<>();
        private final PatternLayoutEncoder encoder;
    
        public MemoryLogAppender(LoggerContext loggerContext, String pattern) {
            super.setContext(loggerContext);
            this.encoder = new PatternLayoutEncoder();
            this.encoder.setContext(loggerContext);
            encoder.setPattern(pattern);
            encoder.start();
        }
    
    
        @Override
        protected void append(ILoggingEvent event) {
            String msg = new String(encoder.encode(event));
            LogEvent logEvent = new LogEvent(msg, event.getLevel(), event.getLoggerName());
            list.add(logEvent);
        }
    
        /**
         * Search for log message matching given regular expression.
         *
         * @param regex Regex to match
         * @return matching log events
         */
        public List<MemoryLogAppender.LogEvent> matches(String regex) {
            return this.list.stream()
                .filter(event -> event.message.matches(regex))
                .collect(Collectors.toList());
        }

测试类

class TestClass

    private static MemoryLogAppender logAppender;
    
        @BeforeAll
        static void beforeAll() {
            logAppender = new MemoryLogAppender((LoggerContext) LoggerFactory.getILoggerFactory(), "%-5level: %message");
            logAppender.start();
        }
    
        @AfterAll
        static void afterAll() {
            logAppender.stop();
        }
    
    ...
    ...

    @Test
    void bad_reference() throws Exception {

        ResponseEntity<HttpStatus> response = testeeService.sendRequest(requestDto).block();

        assertThat(logAppender.search("This reference is bad."))
            .hasSize(1);

        assertTrue(response.getStatusCode().is4xxClientError());
    }

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