第19章 日志系统测试:单元测试框架与压力测试
日志系统写完了,代码看起来也挺漂亮。但说实话,我见过太多"看起来完美"的日志模块,一上线就崩了。为什么?因为没测过。
这一章,我们就来聊聊怎么给日志系统做测试。不是那种随便跑两下就完事的测试,而是正经的单元测试和压力测试。
19.1 为什么日志系统需要专门测试?
你可能觉得奇怪——日志不就是写写文件吗?有什么好测的?
嗯,我以前也这么想。直到有一次,我负责的一个嵌入式设备在产线上跑了三天,突然死机了。查了半天,发现是日志模块在极端情况下把堆栈撑爆了。从那以后,我再也不敢轻视日志系统的测试了。
日志系统有几个容易出问题的地方:
- 并发写入:多线程同时写日志,会不会乱?会不会丢?
- 缓冲区溢出:日志量突然暴增,缓冲区够用吗?
- 文件轮转:日志文件满了之后,切换文件时会不会丢数据?
- 性能退化:日志写多了,会不会拖慢主业务?
这些问题,不测不知道,一测吓一跳。
19.2 单元测试框架选择:CUnit vs Unity
嵌入式C语言的单元测试框架,我常用的有两个:CUnit和Unity。
| 特性 | CUnit | Unity |
|---|---|---|
| 依赖 | 需要标准库支持 | 纯C,几乎无依赖 |
| 安装 | 需要编译安装 | 直接拷贝源文件 |
| 适合场景 | 桌面Linux/Windows | 嵌入式裸机/RTOS |
| 断言类型 | 丰富 | 够用 |
| 输出格式 | XML/文本 | 文本 |
我个人习惯:如果是在PC上做开发调试,用CUnit;如果是交叉编译到嵌入式平台,用Unity。原因很简单——Unity就两个文件,拷进去就能用,省心。
19.3 用Unity给日志系统写单元测试
先看一个实际的例子。假设我们的日志系统有一个核心函数:
// log.h
int log_write(int level, const char* tag, const char* fmt, ...);
我们要测什么?至少这几项:
- 正常写入:写一条日志,检查返回值
- 边界输入:空字符串、超长字符串
- 非法参数:level超出范围、tag为NULL
- 缓冲区满:连续写入直到缓冲区满
用Unity写测试用例,大概是这样的:
#include "unity.h"
#include "log.h"
void setUp(void) {
// 每个测试用例开始前执行
log_init();
}
void tearDown(void) {
// 每个测试用例结束后执行
log_deinit();
}
void test_log_write_normal(void) {
int ret = log_write(LOG_INFO, "test", "hello %d", 123);
TEST_ASSERT_EQUAL_INT(0, ret);
}
void test_log_write_null_tag(void) {
int ret = log_write(LOG_INFO, NULL, "no tag");
TEST_ASSERT_EQUAL_INT(-1, ret);
}
void test_log_write_overflow(void) {
char long_str[2048];
memset(long_str, 'A', sizeof(long_str) - 1);
long_str[sizeof(long_str) - 1] = '\0';
int ret = log_write(LOG_INFO, "test", "%s", long_str);
// 这里要看你的设计:是截断还是返回错误?
TEST_ASSERT_TRUE(ret == 0 || ret == -2);
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_log_write_normal);
RUN_TEST(test_log_write_null_tag);
RUN_TEST(test_log_write_overflow);
return UNITY_END();
}
19.4 日志系统压力测试
单元测试能保证功能正确,但扛不住压力。压力测试才是检验日志系统是否"皮实"的关键。
我曾经遇到一个案例:设备在正常运行时日志量不大,但一旦触发某个告警,日志会像洪水一样涌出来。结果日志模块自己先挂了,连告警都没记下来。你说尴尬不尴尬?
压力测试的核心指标:
- 吞吐量:每秒能写多少条日志?
- 延迟:写一条日志平均耗时多少?
- 稳定性:长时间高负载下会不会崩溃?
- 内存占用:缓冲区会不会无限增长?
下面是一个简单的压力测试代码:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include "log.h"
#define TEST_COUNT 100000
#define BURST_SIZE 100
double time_diff_ms(struct timespec start, struct timespec end) {
return (end.tv_sec - start.tv_sec) * 1000.0 +
(end.tv_nsec - start.tv_nsec) / 1000000.0;
}
void stress_test_continuous(void) {
struct timespec t1, t2;
clock_gettime(CLOCK_MONOTONIC, &t1);
for (int i = 0; i < TEST_COUNT; i++) {
log_write(LOG_INFO, "stress", "iteration %d", i);
}
clock_gettime(CLOCK_MONOTONIC, &t2);
double total_ms = time_diff_ms(t1, t2);
printf("连续写入 %d 条日志\n", TEST_COUNT);
printf("总耗时: %.2f ms\n", total_ms);
printf("平均每条: %.3f us\n", total_ms * 1000 / TEST_COUNT);
}
void stress_test_burst(void) {
struct timespec t1, t2;
clock_gettime(CLOCK_MONOTONIC, &t1);
for (int burst = 0; burst < 100; burst++) {
for (int i = 0; i < BURST_SIZE; i++) {
log_write(LOG_WARN, "burst", "burst %d msg %d", burst, i);
}
// 模拟业务处理间隔
usleep(1000); // 1ms
}
clock_gettime(CLOCK_MONOTONIC, &t2);
double total_ms = time_diff_ms(t1, t2);
printf("突发写入 %d 批,每批 %d 条\n", 100, BURST_SIZE);
printf("总耗时: %.2f ms\n", total_ms);
}
int main(void) {
log_init();
printf("=== 日志系统压力测试 ===\n\n");
stress_test_continuous();
printf("\n");
stress_test_burst();
log_deinit();
return 0;
}
19.5 测试结果分析
跑完测试,数据摆在那里,怎么看?
我个人习惯关注这几个点:
- 平均延迟:如果超过1ms,就要警惕了。日志写入不应该拖慢主业务。
- 最大延迟:有时候平均延迟很低,但偶尔一次特别高。这种"毛刺"在实时系统中是致命的。
- 内存趋势:长时间运行,内存占用应该是稳定的。如果持续增长,说明有内存泄漏。
- 丢日志率:高负载下,日志系统可以选择丢弃部分日志,但不能丢得太多。
下面这张图展示了压力测试的核心流程:
19.6 常见问题与避坑指南
做日志系统测试,有几个坑我踩过,分享给你:
坑1:测试环境与生产环境不一致
我曾经在开发板上测试日志系统,一切正常。结果部署到产品上,发现日志写入速度慢了10倍。原因?开发板用的是SD卡,产品用的是NAND Flash,写入速度差了一个数量级。
解决办法:压力测试一定要在目标硬件、目标文件系统上跑。
坑2:只测正常情况,不测异常
很多人写测试用例,只测"正常写入"这一条。但日志系统最容易出问题的地方,恰恰是异常情况——磁盘满了、文件打开失败、缓冲区溢出。
解决办法:每个函数至少写一个异常测试用例。比如故意把磁盘塞满,看日志系统会不会崩溃。
坑3:忽略多线程并发
单线程测试跑得稳稳的,一上多线程就各种问题。日志系统如果加了锁,要测锁的竞争;如果没加锁,要测数据竞争。
解决办法:用多个线程同时写日志,持续跑几个小时,看有没有死锁、数据错乱。
19.7 测试自动化
手动测试一次两次还行,每次改完代码都跑一遍,谁也受不了。所以测试一定要自动化。
我常用的做法是写一个Makefile,把单元测试和压力测试都集成进去:
# Makefile 片段
.PHONY: test stress
test:
gcc -o ut_log ut_log.c log.c unity.c -I.
./ut_log
stress:
gcc -o stress_log stress_log.c log.c -I. -lrt
./stress_log
all: test stress
每次提交代码前,跑一下make all。如果测试没过,代码就不允许合入。这个习惯帮我挡住了很多低级错误。
19.8 小结
日志系统的测试,说白了就两件事:功能对不对,扛不扛得住。单元测试解决"对不对"的问题,压力测试解决"扛不扛得住"的问题。
我个人建议:每个日志模块的函数都要有对应的单元测试,覆盖率至少80%以上。压力测试要模拟最恶劣的场景——突发写入、多线程并发、磁盘满。只有把这些场景都覆盖到了,你才能放心地把日志系统部署到产品上。
记住一句话:日志系统是用来记录故障的,它自己不能成为故障。
公众号:蓝海资料掘金营,微信deep3321