1. 引言

在单元测试中,我们通常关注方法的返回值或状态变更,但日志输出同样重要,尤其是当它作为系统行为的关键观测点时。本文将介绍如何在 JUnit 测试中对日志进行断言。

我们将使用 SLF4J 作为日志门面Logback 作为具体实现,并通过自定义一个内存型 Appender 来捕获日志,从而实现灵活的断言。这种方式简单粗暴,但非常有效,尤其适合验证关键业务流程中的日志是否按预期输出。

2. Maven 依赖

首先引入必要的依赖项。

✅ Logback 经典实现,它天然实现了 SLF4J API,因此会自动带入 slf4j-api

<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.6</version>
</dependency>

✅ 测试断言利器 AssertJ,提升断言可读性和表达力:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.0</version>
    <scope>test</scope>
</dependency>

3. 业务功能示例

我们创建一个简单的 BusinessWorker 类,用于生成不同级别的日志,作为测试目标:

public class BusinessWorker {
    private static final 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. 日志测试方案

4.1. 自定义 MemoryAppender

核心思路是:通过一个内存 Appender 捕获所有日志事件,然后在测试中进行断言

我们继承 Logback 提供的 ListAppender<ILoggingEvent>,并扩展一些实用方法:

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);
    }
}

📌 主要方法说明:

  • reset():清空日志列表,便于复用
  • contains(msg, level):判断是否存在指定内容和级别的日志
  • countEventsForLogger(loggerName):统计某个 Logger 产生的日志数量
  • search(...):支持按内容或内容+级别查找日志事件
  • getSize():获取当前捕获的日志总数
  • getLoggedEvents():返回不可变日志列表,供进一步分析

4.2. 编写单元测试

我们需要在测试前将 MemoryAppender 注入到目标 Logger 中,并设置合适的日志级别。

public class BusinessWorkerTest {
    private static final String LOGGER_NAME = "com.example.junit.log.BusinessWorker";
    private static final String MSG = "message";
    private MemoryAppender memoryAppender;

    @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();
    }
}

📌 要点:

  • 使用 LoggerFactory.getLogger() 获取目标类的 Logger 实例
  • 设置日志级别为 DEBUG,这样 DEBUG 及以上级别(INFO、WARN、ERROR)都会被捕获,但 TRACE 不会(TRACE 优先级低于 DEBUG)
  • 启动 Appender,开始监听日志

接下来编写测试用例:

@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();
}

✅ 断言说明:

  • 总共应生成 4 条日志(DEBUG、INFO、WARN、ERROR),因为 TRACE 被过滤
  • INFO 级别应有 1 条匹配消息
  • 不应存在 TRACE 级别的日志

⚠️ 踩坑提醒:
如果多个测试共用同一个 MemoryAppender 实例,务必在 @Before@After 中调用 reset(),否则日志会累积,可能导致内存溢出(OutOfMemoryError)。

另外,建议将 Appender 绑定到具体类或包名,避免使用 Logger.ROOT_LOGGER_NAME 捕获全部日志,防止性能和内存问题。

4.3. 多级别日志的验证

当业务逻辑产生多个级别的日志时(如交易系统中的 INFO、WARN、ERROR),我们可以分别断言:

@Test
public void whenMultipleLogLevel_thenReturnExpectedResult() {
    BusinessWorker worker = new BusinessWorker();
    worker.generateLogs("Transaction started for Order ID: 1001");

    assertThat(memoryAppender.countEventsForLogger(LOGGER_NAME)).isEqualTo(4);
    assertThat(memoryAppender.search("Transaction started", Level.INFO).size()).isEqualTo(1);
    assertThat(memoryAppender.search("Transaction started", Level.WARN).size()).isEqualTo(1);
    assertThat(memoryAppender.search("Transaction started", Level.ERROR).size()).isEqualTo(1);
    assertThat(memoryAppender.search("Transaction started", Level.TRACE)).isEmpty();
}

📌 再次强调:DEBUG 级别 Logger 会输出 DEBUG 及更高级别日志,但不会输出 TRACE

4.4. 使用正则匹配动态日志内容

日志中常包含动态内容,如时间戳、用户 ID、订单号等。此时直接字符串匹配会失败,应使用正则表达式。

我们在 MemoryAppender 中添加正则匹配支持:

boolean containsPattern(Pattern pattern, Level level) {
    return this.list.stream()
      .filter(event -> event.getLevel().equals(level))
      .anyMatch(event -> pattern.matcher(event.getFormattedMessage()).matches());
}

📌 getFormattedMessage() 返回格式化后的完整消息,适合做正则匹配。

示例:验证订单处理日志中的订单号格式(5位数字):

@Test
public void whenUsingPattern_thenReturnExpectedResult() {
    BusinessWorker worker = new BusinessWorker();
    worker.generateLogs("Order processed successfully for Order ID: 12345");

    Pattern orderPattern = Pattern.compile(".*Order ID: \\d{5}.*");

    assertThat(memoryAppender.containsPattern(orderPattern, Level.INFO)).isTrue();
    assertThat(memoryAppender.containsPattern(orderPattern, Level.WARN)).isTrue();
    assertThat(memoryAppender.containsPattern(orderPattern, Level.ERROR)).isTrue();
    assertThat(memoryAppender.containsPattern(orderPattern, Level.TRACE)).isFalse();
}

✅ 正则 .*Order ID: \\d{5}.* 确保消息中包含“Order ID:”后跟5位数字,避免因订单号变化导致测试失败。

批量模式匹配

若需同时验证多个模式(如用户登录日志中的用户名和时间戳),可扩展方法:

boolean containsPatterns(List<Pattern> patternList, Level level) {
    return patternList.stream()
      .allMatch(pattern -> containsPattern(pattern, level));
}

测试示例:

@Test
public void whenUsingMultiplePatterns_thenReturnExpectedResult() {
    BusinessWorker worker = new BusinessWorker();
    worker.generateLogs("User Login: username=user123, timestamp=2024-11-25T10:15:30");

    List<Pattern> patterns = List.of(
      Pattern.compile(".*username=user\\w+.*"),
      Pattern.compile(".*timestamp=\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.*")
    );

    assertThat(memoryAppender.containsPatterns(patterns, Level.INFO)).isTrue();
    assertThat(memoryAppender.containsPatterns(patterns, Level.WARN)).isTrue();
}

5. 总结

本文介绍了如何在 JUnit 测试中对日志进行有效断言:

  • ✅ 通过自定义 MemoryAppender 捕获日志事件
  • ✅ 支持按内容、级别、数量进行断言
  • ✅ 使用正则表达式处理动态日志内容,提升测试鲁棒性

该方案无需引入额外框架,轻量且灵活,适合大多数基于 SLF4J + Logback 的项目。在关键路径的日志验证中使用,能显著提升代码可观测性和质量。

完整代码示例可参考:GitHub - testing-assertions


原始标题:Asserting Log Messages With JUnit | Baeldung