第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();
}
小技巧:setUp和tearDown一定要写好。我见过有人把所有测试塞到一个函数里,结果出了问题根本不知道是哪个用例导致的。每个用例独立、干净,这是单元测试的基本素养。

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;
}
注意:压力测试一定要在目标硬件上跑。在PC上测出来每秒10万条,到嵌入式板子上可能只有几千条。我吃过这个亏,在x86上跑得飞起,移植到ARM Cortex-M上直接卡死。

19.5 测试结果分析

跑完测试,数据摆在那里,怎么看?

我个人习惯关注这几个点:

  1. 平均延迟:如果超过1ms,就要警惕了。日志写入不应该拖慢主业务。
  2. 最大延迟:有时候平均延迟很低,但偶尔一次特别高。这种"毛刺"在实时系统中是致命的。
  3. 内存趋势:长时间运行,内存占用应该是稳定的。如果持续增长,说明有内存泄漏。
  4. 丢日志率:高负载下,日志系统可以选择丢弃部分日志,但不能丢得太多。

下面这张图展示了压力测试的核心流程:

日志系统压力测试流程 1. 初始化日志系统 2. 连续写入测试 3. 突发写入测试 4. 收集性能数据 5. 分析测试结果 ✅ 通过:优化参数 ❌ 失败:修复问题 压力测试需要反复迭代,直到系统稳定通过

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