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