16、测试策略:单元测试动态库、Mock插件、集成测试框架
说实话,很多做C语言插件化架构的朋友,最头疼的不是怎么写代码,而是怎么测。我见过太多项目,插件写得飞起,一上线就崩。为什么?因为动态库的测试,跟普通单体的测试完全是两码事。
你想想看,一个插件在加载前,你根本不知道它里面是什么。它可能是个好插件,也可能是个炸弹。所以,测试策略必须从设计的第一天就考虑进去。我个人习惯把测试分成三层:单元测试、Mock测试、集成测试。咱们一层一层说。
16.1 单元测试动态库:别让dlopen成为黑盒
单元测试动态库,说白了就是测试每个插件内部的函数。但有个坑——动态库里的函数,默认是不导出的。你写了个static函数,外面根本调不到。
我在项目中遇到过这种情况:插件内部有个核心算法,我想单独测它,结果发现它被声明成static了。怎么办?我的做法是:在测试编译时,用宏控制符号可见性。
核心思路:生产代码中,非公开函数用static隐藏;测试编译时,通过宏把static变成空,让测试代码能直接调用。
看个例子:
// plugin_core.h
#ifdef UNIT_TEST
#define TESTABLE
#else
#define TESTABLE static
#endif
TESTABLE int internal_calc(int a, int b) {
return a * b + a / b; // 假设这是核心逻辑
}
// 公开接口
int plugin_process(int x);
这样,在单元测试文件里,你只需要#define UNIT_TEST,就能直接调用internal_calc。嗯,这里要注意:千万别把这个宏带到生产编译里,否则符号全暴露了。
另外,动态库的单元测试,我建议用测试框架 + 动态加载的方式。比如用CUnit或Check,在测试用例里先dlopen加载.so文件,再dlsym拿到函数指针,然后调用。这样测的是真正的二进制产物,不是源码。
小技巧:写一个通用的测试加载器,传入.so路径和函数名,自动完成dlopen→dlsym→调用→dlclose。这样每个测试用例只需要写业务逻辑,不用重复写加载代码。
16.2 Mock插件:让依赖变得可控
插件化架构里,插件之间经常有依赖。比如插件A要调用插件B的接口。如果插件B还没写好,或者B依赖硬件、网络,你怎么测A?
答案就是Mock。说白了,就是造一个假的插件B,行为完全由你控制。
我曾经在一个物联网项目里,插件要跟云端通信。测试时总不能真的连服务器吧?所以我写了一个Mock插件,它实现了同样的接口,但内部只是打印日志、返回预设数据。
具体做法:
- 定义统一的接口头文件,所有插件都实现这个接口。
- 写一个Mock插件,实现同样的接口,但行为可配置。
- 在测试环境中,用Mock替换真实插件。
代码示例:
// plugin_interface.h
typedef struct {
int (*init)(void);
int (*process)(const char* input, char* output);
void (*cleanup)(void);
} plugin_ops_t;
// mock_plugin.c
#include "plugin_interface.h"
#include <stdio.h>
#include <string.h>
static int mock_init(void) {
printf("[MOCK] init called\n");
return 0; // 总是成功
}
static int mock_process(const char* input, char* output) {
printf("[MOCK] process called with: %s\n", input);
// 返回预设数据
strcpy(output, "mock_result");
return 0;
}
static void mock_cleanup(void) {
printf("[MOCK] cleanup called\n");
}
plugin_ops_t mock_plugin = {
.init = mock_init,
.process = mock_process,
.cleanup = mock_cleanup
};
注意:Mock插件一定要跟真实插件使用完全相同的接口头文件。我曾经因为头文件版本不一致,导致函数指针偏移量对不上,程序直接段错误。排查了一下午才发现是结构体成员顺序变了。
Mock的好处是:你可以模拟各种边界情况。比如让mock_init返回-1,测试插件A对初始化失败的处理;让mock_process永远超时,测试超时逻辑。这些在真实环境里很难复现。
16.3 集成测试框架:把插件拼起来跑
单元测试和Mock都过了,不代表系统能跑。集成测试,就是把所有插件加载到一起,模拟真实场景跑一遍。
我个人习惯用Python + ctypes写集成测试。为什么?因为Python写测试脚本快,而且ctypes可以直接调用C动态库,不需要编译。
基本流程:
- 启动主程序(或者自己写一个测试用的小框架)。
- 按顺序加载所有插件。
- 构造输入数据,调用插件链。
- 检查输出结果是否符合预期。
- 卸载插件,清理资源。
这里有个关键点:集成测试一定要覆盖插件的加载和卸载顺序。我遇到过插件A在init时申请了内存,插件B在cleanup时释放了A的内存——结果卸载顺序一变,直接double free。
我的经验:写一个测试脚本,专门测试插件的加载/卸载顺序。比如先加载A再加载B,然后先卸载A再卸载B;再试先加载B再加载A,先卸载B再卸载A。各种排列组合跑一遍,能发现很多隐藏问题。
另外,集成测试里一定要加资源泄漏检测。动态库加载后,内存、文件描述符、线程都可能泄漏。我常用的工具是Valgrind,配合dlclose后的内存检查。
下面这张图,是我总结的测试策略整体流程:
16.4 避坑指南:我踩过的那些坑
最后,分享几个我实际项目中踩过的坑,希望能帮你少走弯路。
| 坑 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 符号冲突 | 插件A调用了插件B的函数,但结果不对 | 两个插件定义了同名全局函数,dlsym返回了错误的地址 |
所有插件函数加前缀,比如pluginA_、pluginB_ |
| 静态变量污染 | 多次加载卸载后,插件行为异常 | 插件内部用了static变量,第二次加载时值没重置 |
在init函数里显式初始化所有状态 |
| dlclose不彻底 | Valgrind报告内存泄漏,但代码里明明释放了 | 插件内部有线程或定时器没停止,导致.so引用计数不为0 | 在cleanup里确保所有后台线程已退出 |
| Mock与真实接口不一致 | Mock测试全过,集成测试全挂 | Mock插件用的头文件版本跟真实插件不一样 | Mock和真实插件共用同一个头文件,用#include确保一致 |
我的建议:测试不是一次性的工作。每次新增插件或修改接口,都要重新跑一遍所有测试。我习惯在CI/CD里配置自动化测试,每次提交代码自动触发。这样能尽早发现问题,而不是等到上线前才手忙脚乱。
好了,关于测试策略,我就讲这么多。记住:测试不是负担,而是保护网。插件化架构的灵活性,必须靠完善的测试来兜底。否则,灵活就会变成失控。
公众号:蓝海资料掘金营,微信deep3321