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插件,它实现了同样的接口,但内部只是打印日志、返回预设数据。

具体做法:

  1. 定义统一的接口头文件,所有插件都实现这个接口。
  2. 写一个Mock插件,实现同样的接口,但行为可配置。
  3. 在测试环境中,用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动态库,不需要编译。

基本流程:

  1. 启动主程序(或者自己写一个测试用的小框架)。
  2. 按顺序加载所有插件。
  3. 构造输入数据,调用插件链。
  4. 检查输出结果是否符合预期。
  5. 卸载插件,清理资源。

这里有个关键点:集成测试一定要覆盖插件的加载和卸载顺序。我遇到过插件A在init时申请了内存,插件B在cleanup时释放了A的内存——结果卸载顺序一变,直接double free。

我的经验:写一个测试脚本,专门测试插件的加载/卸载顺序。比如先加载A再加载B,然后先卸载A再卸载B;再试先加载B再加载A,先卸载B再卸载A。各种排列组合跑一遍,能发现很多隐藏问题。

另外,集成测试里一定要加资源泄漏检测。动态库加载后,内存、文件描述符、线程都可能泄漏。我常用的工具是Valgrind,配合dlclose后的内存检查。

下面这张图,是我总结的测试策略整体流程:

插件化架构测试策略流程图 插件源代码 单元测试(CUnit/Check) Mock测试(模拟依赖) 集成测试(Python+ctypes) 检查:函数逻辑 边界条件、返回值 检查:接口兼容性 异常场景模拟 检查:加载/卸载顺序 资源泄漏、内存 全部通过 → 发布

16.4 避坑指南:我踩过的那些坑

最后,分享几个我实际项目中踩过的坑,希望能帮你少走弯路。

现象 原因 解决方案
符号冲突 插件A调用了插件B的函数,但结果不对 两个插件定义了同名全局函数,dlsym返回了错误的地址 所有插件函数加前缀,比如pluginA_pluginB_
静态变量污染 多次加载卸载后,插件行为异常 插件内部用了static变量,第二次加载时值没重置 init函数里显式初始化所有状态
dlclose不彻底 Valgrind报告内存泄漏,但代码里明明释放了 插件内部有线程或定时器没停止,导致.so引用计数不为0 cleanup里确保所有后台线程已退出
Mock与真实接口不一致 Mock测试全过,集成测试全挂 Mock插件用的头文件版本跟真实插件不一样 Mock和真实插件共用同一个头文件,用#include确保一致

我的建议:测试不是一次性的工作。每次新增插件或修改接口,都要重新跑一遍所有测试。我习惯在CI/CD里配置自动化测试,每次提交代码自动触发。这样能尽早发现问题,而不是等到上线前才手忙脚乱。

好了,关于测试策略,我就讲这么多。记住:测试不是负担,而是保护网。插件化架构的灵活性,必须靠完善的测试来兜底。否则,灵活就会变成失控。


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