测试错误处理:如何测试函数返回错误码、异常分支的覆盖

大家好,我是老李。今天我们来聊聊单元测试里一个特别容易被忽视、但又特别重要的环节——错误处理测试。

说实话,我见过太多项目了。单元测试覆盖率报告上写着 90%,结果一上线,系统就崩了。为什么?因为那 90% 都是「快乐路径」——所有参数都合法、所有资源都就绪、所有调用都成功。但真实世界不是这样的。网络会断、内存会满、文件会不存在、用户会输入非法数据。如果你的测试只覆盖了「一切顺利」的情况,那这个测试其实没什么用。

我个人习惯是:写测试时,先想「这个函数会怎么失败」,然后再想「它怎么成功」。今天我们就来系统讲讲,怎么测试错误码和异常分支。

一、错误码测试:别只检查返回值

很多 C 函数通过返回值来报告错误。比如:

int open_file(const char *path, FileHandle *handle);

返回 0 表示成功,返回 -1 表示失败。但光检查「返回 -1」是不够的。你得问自己几个问题:

  • 函数在哪些情况下会返回 -1?
  • 每种情况下的 errno 设置正确吗?
  • 调用方是否真的能区分不同的错误原因?

我在项目中遇到过这样一个坑:一个驱动函数,无论什么错误都返回 -1,然后调用方只能靠 errno 来区分。但测试时只测了「返回 -1」,没测 errno 的值。结果上线后,文件不存在和权限不足被混为一谈,系统做出了错误的处理。

所以,正确的做法是:

// 测试文件不存在
TEST(test_open_file_file_not_found) {
    FileHandle handle;
    int ret = open_file("/nonexistent/path", &handle);
    ASSERT_EQ(ret, -1);
    ASSERT_EQ(errno, ENOENT);
}

// 测试权限不足
TEST(test_open_file_permission_denied) {
    FileHandle handle;
    int ret = open_file("/etc/shadow", &handle);
    ASSERT_EQ(ret, -1);
    ASSERT_EQ(errno, EACCES);
}

核心原则:错误码测试不仅要测「是否返回错误」,还要测「返回了哪个错误」。不同的错误码代表不同的语义,调用方依赖这些语义来做决策。

二、异常分支覆盖:把「不可能」变成「可能」

什么叫异常分支?说白了就是那些「理论上不会发生,但万一发生了呢」的路径。比如:

  • malloc 返回 NULL
  • 文件读取到一半被删除
  • 网络连接在传输过程中断开
  • 定时器回调在临界区被触发

很多开发者觉得「malloc 失败的概率太低了,不用测」。嗯,我以前也这么想,直到有一次在嵌入式设备上,内存碎片化导致 malloc 在某个特定场景下反复失败。那一次排查花了我整整两天。

所以我的建议是:所有可能失败的资源分配,都要有对应的测试用例。

怎么测?用「模拟失败」的技巧。比如:

// 被测函数
int process_data(const char *input) {
    char *buffer = (char *)malloc(1024);
    if (!buffer) {
        return ERR_NO_MEMORY;
    }
    // ... 处理数据 ...
    free(buffer);
    return SUCCESS;
}

// 测试 malloc 失败
TEST(test_process_data_malloc_fails) {
    // 用一个 hook 或者 mock 来让 malloc 返回 NULL
    malloc_set_fail_mode(FAIL_ALWAYS);
    
    int ret = process_data("test");
    ASSERT_EQ(ret, ERR_NO_MEMORY);
    
    malloc_set_fail_mode(FAIL_NONE);
}

小技巧:在嵌入式开发中,我经常用「内存压力测试」来模拟极端情况。先分配大量内存,让系统处于低内存状态,然后再调用被测函数。这样不用改代码,就能触发 malloc 失败路径。

三、边界条件:错误往往发生在「临界点」

错误处理还有一个容易被忽略的地方——边界条件。比如:

  • 缓冲区刚好满的时候
  • 计数器刚好溢出的时候
  • 超时时间刚好到的时候

我举个例子。一个网络接收函数,缓冲区大小是 1024 字节。正常情况没问题,但如果对方一次性发来 1025 字节呢?函数应该返回什么?是截断还是报错?

// 测试缓冲区溢出
TEST(test_receive_buffer_overflow) {
    uint8_t large_data[1025] = {0};
    uint8_t buffer[1024];
    int ret = receive_data(large_data, 1025, buffer, 1024);
    ASSERT_EQ(ret, ERR_BUFFER_TOO_SMALL);
    // 或者:ASSERT_EQ(ret, 1024); // 如果设计是截断
}

你看,这里没有标准答案。关键是你的测试要明确告诉别人:这个边界情况下,函数的行为是什么。

四、状态机中的错误路径

很多嵌入式系统用状态机来管理流程。状态机里的错误处理尤其复杂,因为一个错误可能导致状态跳转到完全不同的分支。

我记得有一次,一个通信协议的状态机,在「等待 ACK」状态下收到一个 NAK,应该跳转到「重试」状态。但代码里写错了,跳到了「空闲」状态。结果整个通信链路就断了。这个 bug 在代码审查里没发现,因为审查者只看了「快乐路径」的状态转换。

所以,对于状态机,我建议用状态转换表来设计测试用例:

当前状态 输入事件 期望下一状态 期望输出
等待 ACK 收到 NAK 重试 发送重试请求
等待 ACK 超时 重试 发送重试请求
等待 ACK 收到非法数据 错误处理 记录日志,断开连接

然后针对每一行写一个测试用例。这样,所有错误路径都被覆盖到了。

五、错误处理测试的「避坑指南」

最后,分享几个我踩过的坑:

我曾经犯过的错:

  • 只测一次错误:有些函数在第一次失败后会进入「降级模式」,第二次调用时的行为可能完全不同。要测多次连续失败。
  • 忽略资源清理:错误路径上,已经分配的资源是否被正确释放?我见过一个函数,失败时忘了 free 临时缓冲区,导致内存泄漏。测试时要用内存检测工具配合。
  • 错误码冲突:不同模块的错误码定义可能重叠。比如模块 A 的 ERR_TIMEOUT 是 3,模块 B 的 ERR_BUSY 也是 3。调用方如果混用,就会误判。测试时要检查错误码的唯一性。
  • 日志副作用:有些错误处理函数会写日志。如果日志系统本身也出错了呢?比如磁盘满了。这会导致递归错误。测试时要考虑「错误中的错误」。

六、知识体系总览

下面这张图总结了错误处理测试的核心要点,我把它画成了流程图,方便你对照检查自己的测试用例:

错误处理测试知识体系 被测函数 函数执行过程中可能发生什么? 参数非法/边界值 资源分配失败 运行时异常/超时 测试:传入非法参数 测试:模拟 malloc 失败 测试:模拟超时/断连 验证:错误码 + 资源释放 + 状态转换

这张图的核心逻辑是:先枚举所有可能的失败原因,然后针对每种原因设计测试方法,最后统一验证三个维度——错误码是否正确、资源是否释放、状态是否按预期转换。

好了,关于错误处理测试,今天就聊到这里。记住一句话:测试不是为了证明代码能跑,而是为了证明代码在极端情况下不会崩。 下次写测试时,多想想「如果这里出错了会怎样」,你的测试质量会提升一大截。


公众号:蓝海资料掘金营,微信 deep3321