OpenSSL 单元测试实战指南:基于 cmocka 与 --wrap 的隔离测试体系(test/unit)
【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl
OpenSSL 作为一个承载 TLS 与密码学核心逻辑的通用库,其大量代码路径依赖网络、文件系统与系统调用,常规集成式测试难以精确触达深层错误分支。本文基于仓库内 test/unit/README.md 展开,系统讲解 OpenSSL 如何在test/unit/目录下,借助轻量级 C 单元测试框架 cmocka 与 GNU/BSD 链接器的--wrap选项,对单个函数或小规模函数组进行隔离测试:你将掌握从构建启用、测试二进制组织、mock 编程、fixture 管理,到通过build.info与util/mkwraps.pl接入构建体系的完整实战方法。
单元测试的定位:为难以触达的代码路径而生
test/unit/目录存放的是 OpenSSL 的unit测试,其核心目的非常明确:通过替换被测函数所调用的其他函数(mock),在隔离环境中测试单个函数或一小群相关函数。这种做法的价值在于,它让测试能够覆盖通常难以触达的逻辑:
- 依赖项的错误路径与失败分支(例如系统调用返回错误码);
- 依赖精确参数取值才能走到的分支;
- 真实依赖需要网络访问、特定硬件或复杂环境搭建的代码。
每个测试可以驱动被测函数(Function Under Test)经历一次完全受控的调用序列与返回值序列,并精确断言它与周边环境的交互方式。
需要强调的是,单元测试并不旨在取代test/下占多数的集成式测试,也并非万灵药。它只用于库中边界清晰、收益高的自包含片段,BIO 层是当前最主要的使用场景(可从 test/unit/build.info 看到,现有全部单元测试均位于crypto/bio/下)。其余大多数代码仍然通过常规 recipes(通常基于testutil,一般不使用 mock)进行测试。
前置要求:cmocka、--wrap 与 enable-unit-tests
单元测试构建在cmocka(一个轻量级 C 单元测试框架)之上,并依赖 GNU/BSD 链接器的--wrap选项在链接期拦截对被测函数依赖项的调用。由于--wrap是硬性要求,单元测试仅在支持它的平台上构建——目前只有 Linux 与 BSD——并且只有在构建配置了enable-unit-tests时才会编译。
cmocka 版本兼容性约束
测试必须能够针对cmocka 1.1.5构建并运行,因为该版本仍是当前部分受支持的企业级与 LTS 发行版所内置的版本。不要依赖 cmocka 2.x 中引入的 API——依赖它们的测试将无法在这些系统上构建。拿不准时,应以 1.1.5 的头文件为准进行核对,而不是参考最新的在线文档。
构建与运行
单元测试默认是关闭的。要构建它们,需要在配置时加上enable-unit-tests,并确保系统中已安装 cmocka 开发文件:
$ sudo apt-get install libcmocka-dev # Debian/Ubuntu $ ./config enable-unit-tests $ make如果 cmocka 安装在非标准位置,可通过如下选项将构建指向它:
$ ./config enable-unit-tests \ --with-cmocka-include=/path/to/include \ --with-cmocka-lib=/path/to/lib在 Configure 中可以看到这两个选项的解析与处理:配置脚本将--with-cmocka-include记录的路径加入cmocka_includes,将--with-cmocka-lib拼装为-L<dir> -lcmocka形式的cmocka_libs;同时源码中还有一条关键逻辑——只要某个目标声明了WRAP[]条目,就隐式地为其附加 cmocka 依赖("WRAP implies cmocka"),cmocka 的头文件搜索路径也会自动注入到这类目标的编译参数中。
在不支持--wrap的平台上,enable-unit-tests会在配置阶段被静默关闭,其余构建流程照常进行。
运行方式一:随全套测试一起跑
整个单元测试套件作为正常测试目标的一部分运行:
$ make test运行方式二:单独运行 test_unit
单元测试被收敛在单一 recipe之下,因此也可以单独执行:
$ make test TESTS=test_unit该 recipe 的实现位于 test/recipes/02-test_unit.t:它用File::Find递归扫描构建树下的test/unit目录,发现每个名为test_*的可执行文件并逐一运行。因此新增的测试二进制只要构建出来就会被自动纳入,无需改动 recipe。每个二进制以TAP格式输出结果,由 harness 直接消费。
运行方式三:直接执行单个二进制(调试利器)
由于每个测试都是普通的独立可执行程序,也可以直接运行——这在调试单个失败时非常方便:
$ gdb test/unit/crypto/foo/test_bar直接运行二进制会把 TAP 输出打印到终端,并且可以方便地在某个测试、某个 mock 或被测函数中设置断点。
为了获得更好的调试体验,建议同时用--debug配置构建,例如./config enable-unit-tests --debug。普通构建是经过优化的,会导致单步执行和变量检视很不方便;--debug降低优化级别并加入调试信息,让 gdb 下的调试体验更可控。
单元测试文件的解剖结构
一个单元测试是单个 C 源文件,放在test/unit/下,路径镜像被测试代码的位置。例如位于crypto/foo/bar.c的代码,由test/unit/crypto/foo/test_bar.c测试。文件是自包含的:自带main()、注册一组测试用例,并作为一个 cmocka group 运行。
测试文件的主体按约定分为几个界限清晰的段落,通常用简短注释标出,顺序如下:
__wrap_*mock 实现(/* wraps */);- 为每个 mock 编程的薄封装
expect_*辅助函数(/* expectations */); - 共享辅助函数(fake 方法、访问器、重置例程);
setup/teardownfixtures;- 测试函数本身;
main()——构建CMUnitTest数组并运行它。
保持这些段落的顺序并加上清晰标签,能让测试文件易于阅读和扩展。仓库中的 test/unit/crypto/bio/test_bio_addr.c 是这一结构的完整范例:文件先以/* wraps */声明__wrap_BIO_sock_init、__wrap_getnameinfo、__wrap_freeaddrinfo,随后是/* expectations */段的expect_sock_init、expect_getnameinfo、expect_freeaddrinfo辅助函数,最后是数十个test_*测试函数与main()。
main() 与测试列表
main()声明一个struct CMUnitTest数组,选择 TAP 输出,并运行整个 group:
int main(void) { const struct CMUnitTest tests[] = { cmocka_unit_test(test_something_simple), cmocka_unit_test_setup_teardown(test_with_fixture, setup, teardown), }; cmocka_set_message_output(CM_OUTPUT_TAP); return cmocka_run_group_tests(tests, NULL, NULL); }要点:
- 务必选择
CM_OUTPUT_TAP,否则 harness 无法解析结果; - 无需 per-test fixture 时用
cmocka_unit_test();需要在测试前后构建/清理新对象时用cmocka_unit_test_setup_teardown(); cmocka_run_group_tests()的最后两个参数是可选的 group 级 setup 与 teardown,在整个 group 前后各执行一次;不需要时传NULL。
当大多数测试共享同一 fixture 时,常见的做法是把注册宏再包一层本地宏,例如:
#define MY_TEST(name) \ cmocka_unit_test_setup_teardown(name, setup, teardown)在 test/unit/crypto/bio/test_bss_acpt.c 末尾可以看到类似的#define ACPT_TEST(name) cmocka_unit_test_setup_teardown(name, setup, teardown),以及通过cmocka_run_group_tests(tests, group_setup, group_teardown)挂接 group 级 setup/teardown 的用法。
一个测试函数
每个测试都是签名为void (void **state)的函数。state参数携带setupfixture 存入的内容;不使用它的测试应将其强转为void以消除告警:
static void test_addr_family(void **state) { BIO_ADDR ap; (void)state; memset(&ap, 0, sizeof(ap)); ap.sa.sa_family = AF_INET; assert_int_equal(BIO_ADDR_family(&ap), AF_INET); }断言来自 cmocka:assert_int_equal、assert_ptr_equal、assert_true、assert_false、assert_null、assert_non_null、assert_string_equal、assert_memory_equal等。断言失败会中止当前测试并将其标记为失败,但不会干扰其他测试。
期望机制(Expectation Mechanism)的工作原理
在编写 mock 之前,理解 cmocka 实际在做什么很有帮助——这个模型一旦讲明白就非常直观,其余 API 也就顺理成章了。
对每个(函数, 参数)对,cmocka 维护一个内部队列:
- 测试中调用的
expect_*()宏把值推入这些队列; - mock 内部调用的
check_expected()宏弹出下一个值,与 mock 实际收到的参数比较,不匹配则测试失败; - 返回值机制相同:测试用
will_return()推入一个值,mock 内用mock_type()/mock_ptr_type()弹出作为返回值; - 调用次数记账类似:
expect_function_call()推入一次期望调用,function_called()消耗一次。
因此,测试按顺序编程它期望被测函数发出的调用,mock 则在调用真正发生时按序消耗这些编程条目。条目按入队顺序被消费,这正是测试中的expect_*/will_return调用必须与函数将调用其依赖的顺序一致的原因。测试结束时,如果任何已入队条目从未被消费,或 mock 在没有可消费条目时被调用,cmocka 都会判定失败。这把期望的交互序列变成了一份被校验的规格,而非松散的"建议"。
多次调用与 _count 变体
由于每个条目只覆盖一次调用,期望被调用多次的函数需要按调用发生顺序多次推入条目:
/* the SUT is expected to call BIO_socket twice */ expect_BIO_socket(AF_INET, SOCK_STREAM, IPPROTO_TCP, 0, INVALID_SOCKET); expect_BIO_socket(AF_INET, SOCK_STREAM, IPPROTO_TCP, 0, FAKE_SOCKET);cmocka 还提供_count变体(如will_return_count()、expect_value_count()、expect_function_calls()),可单条语句为给定次数的调用编程一个条目。但它们不只是重复宏的简写,而是携带不同的排序语义:
- 重复
expect_function_call()会钉住每次调用在整体序列中的位置——两个调用之间编程的任何其他期望调用必须真实地发生在它们之间; - 而
expect_function_calls(f, 2)只要求f被调用两次,其他调用出现在之前、之后或中间都不会导致失败。
当交错顺序重要时(通常是常见情形),应优先重复普通宏;只有当某个函数确实被调用很多次、且其相对位置并非测试关注点时,才使用 count 变体。
两个重要推论
- cmocka 与
--wrap无关。期望机制只是这些队列加上check_expected/mock/function_called宏,它适用于任何在函数体内调用它们的函数。--wrap只是 OpenSSL 用来替换依赖为 mock 的链接器技巧,二者相互独立。同样的expect_*风格也用于下面提到的、根本不经过 wrap 的专用 fake 对象(见"Fixtures 与真实 fake 对象")。 - 由于匹配是按参数、按顺序进行的,mock 必须为测试用
expect_*()编程的恰好那些参数调用check_expected(),且will_return/mock_type的数量必须平衡。
用 --wrap 做依赖 mock:链接期函数拦截
核心技术是链接期函数拦截。当二进制以-Wl,--wrap=foo链接时,所有对foo的调用都被重定向到名为__wrap_foo的函数,而原始函数仍可通过__real_foo访问。这让测试能够用记录调用方式、按测试指令返回值的 mock 替换被测函数的依赖。
声明 wraps
被 wrap 的符号集在build.info文件中声明(见下文)。对每个被 wrap 的符号,测试文件需提供与真实函数完全相同签名的__wrap_<name>函数。为了满足-Wmissing-prototypes,还需要一个原型声明:
int __wrap_BIO_socket(int domain, int socktype, int protocol, int options); int __wrap_BIO_socket(int domain, int socktype, int protocol, int options) { function_called(); check_expected(domain); check_expected(socktype); check_expected(protocol); check_expected(options); return mock_type(int); }一个典型的 mock 做三件事:
function_called()记录函数被调用,与测试的expect_function_call()平衡;check_expected(param)(指针用check_expected_ptr(param))对照测试入队的值校验实参;指针参数务必使用_ptr变体;mock_type(T)(指针用mock_ptr_type(T))返回测试为该次调用入队的值;void型 mock 省略此步。
mock 还可以有刻意的副作用——当真实函数会产生被测代码依赖的输出时。例如,填充调用方提供缓冲区的函数,其 mock 应写入该缓冲区;被测代码期望翻转状态标志的致命错误报告器也应如此:
int __wrap_ssl_fill_hello_random(SSL_CONNECTION *s, int server, unsigned char *field, size_t len, DOWNGRADE dgrd) { function_called(); check_expected_ptr(s); check_expected(server); check_expected_ptr(field); check_expected(len); check_expected(dgrd); if (field != NULL) memset(field, 0xAB, len); return mock_type(int); }这些副作用应保持最小化,只局限于被测函数真正观察到的部分——目标是复现真实函数的契约,而不是重新实现它。
编程 mock:expectations
与其在每个测试里散落expect_function_call/expect_value/will_return调用,不如把每个 mock 包进一个小的expect_<name>辅助函数,接收期望参数和返回值。这既让测试可读,更关键的是:当函数签名或调用契约变化时,只需要改一个地方:
static void expect_BIO_socket(int domain, int socktype, int protocol, int options, int rc) { expect_function_call(__wrap_BIO_socket); expect_value(__wrap_BIO_socket, domain, domain); expect_value(__wrap_BIO_socket, socktype, socktype); expect_value(__wrap_BIO_socket, protocol, protocol); expect_value(__wrap_BIO_socket, options, options); will_return(__wrap_BIO_socket, rc); }这里最常用的 cmocka 原语有:
expect_function_call(f):期望对f的一次调用。mock 中每个function_called()都要配对一条。expect_value(f, param, value):实参必须等于value,由check_expected(param)消费。expect_any(f, param):实参可以是任意值;仍由check_expected(param)消费,因此只要 mock 检查该参数,就必须出现。will_return(f, value):为下一次调用入队一个返回值,由mock_type/mock_ptr_type取出。若 mock 要取多个值(例如先返回码、后出参载荷),需按顺序入队多个。
当 mock有条件地取第二个值时,对应的 expectation 必须在同一条件下入队它,以保持队列对齐:
static void expect_BIO_lookup(BIO_ADDRINFO *res, int rc) { expect_function_call(__wrap_BIO_lookup); expect_any(__wrap_BIO_lookup, host); expect_any(__wrap_BIO_lookup, service); expect_value(__wrap_BIO_lookup, lookup_type, BIO_LOOKUP_SERVER); expect_any(__wrap_BIO_lookup, family); expect_any(__wrap_BIO_lookup, socktype); will_return(__wrap_BIO_lookup, rc); if (rc == 1) will_return(__wrap_BIO_lookup, res); }针对 mock 编写测试
辅助函数就位后,测试的读法为:布置对象、声明期望的调用序列、调用被测函数、断言结果与任何可观察状态:
static void test_socket_then_listen_fails(void **state) { BIO *bio = *state; expect_BIO_socket(AF_INET, SOCK_STREAM, IPPROTO_TCP, 0, FAKE_SOCKET); expect_BIO_listen(FAKE_SOCKET, &expected_addr, 0, 0); expect_BIO_closesocket(FAKE_SOCKET, 0); assert_true(BIO_do_accept(bio) <= 0); }如果被测函数发起了未编程的调用、或未发出已编程的调用、或实参不匹配,cmocka 都会使测试失败并报告不匹配。
Fixtures 与"真实 fake 对象"
setup函数分配或初始化测试所需的对象,并通过*state存储;配对的teardown释放它。两者任一返回非零都会以错误中止测试:
static int setup(void **state) { BIO *bio = BIO_new(BIO_s_accept()); assert_non_null(bio); *state = bio; return 0; } static int teardown(void **state) { if (*state != NULL) BIO_free(*state); return 0; }group 级 setup/teardown(cmocka_run_group_tests的最后两个参数)适合放置每个测试共享的一次性工作,例如初始化跨文件使用的静态 fixture。test/unit/crypto/bio/test_bss_acpt.c 中的group_setup就负责构建fake_sink_method,group_teardown负责BIO_meth_free释放它。
当 fixture 与被 wrap 函数交互时,有两条实战注意事项:
- teardown 自身可能触发被 wrap 的调用。如果释放被测对象会调用某个被 wrap 函数(例如关闭 socket),应在释放前把相关字段重置为安全哨兵值,以免产生意外的 mock 调用;或者为它编程期望。常见模式是一个小的
reset_for_teardown()辅助函数,在留下此类状态的测试末尾调用。该模式在test_bss_acpt.c中被广泛使用。 - 用真实的最小 fake 驱动对象往往比什么都 mock 更干净。例如,构造一个小型 fake
BIO_METHOD,其 read/write 回调本身就是 cmocka mock(使用同样的function_called/check_expected/mock_type机制,尽管没有任何东西被 wrap),就可以让测试在不 wrap 底层系统调用的情况下检验被测对象的转发逻辑。test_bss_acpt.c正是这样:fake_sink_read/fake_sink_write/fake_sink_ctrl三个回调配合BIO_meth_set_read_ex/BIO_meth_set_write_ex/BIO_meth_set_ctrl构建出fake_sink_method,再用expect_fake_sink_read/expect_fake_sink_write编程。选择哪种边界,取决于哪种能让测试聚焦于真正被检验的函数。
条件编译
镜像被测试代码的#ifdef/#ifndef守卫。如果某函数只在某构建选项下存在,就用相同条件同时守卫测试函数和它在main()中的注册,使套件在每种配置下都能构建:
#ifndef OPENSSL_NO_UNIX_SOCK static void test_addr_make_unix(void **state) { ... } #endif int main(void) { const struct CMUnitTest tests[] = { #ifndef OPENSSL_NO_UNIX_SOCK cmocka_unit_test(test_addr_make_unix), #endif ... }; ... }当整个测试文件只在某选项下才有意义时,守卫整个文件主体,并为禁用情形提供平凡的main(),使二进制仍能链接、recipe 仍能找到可运行对象:
#ifndef OPENSSL_NO_SOCK /* ... the tests ... */ #else int main(void) { return 0; } #endiftest/unit/crypto/bio/test_bio_addr.c 是这种整文件守卫的实例:顶部#ifdef OPENSSL_NO_SOCK分支只提供返回 0 的main(),#else分支才是完整的测试;文件内部又以#if OPENSSL_USE_IPV6、#ifndef OPENSSL_NO_UNIX_SOCK、#ifdef AI_PASSIVE分别守卫 IPv6、UNIX socket 与 getnameinfo 相关测试及其在main()中的注册。
将新测试接入构建:build.info
测试二进制在 test/unit/build.info 中声明。不 wrap 任何符号的单元测试不会链接 cmocka,因此每个单元测试必须至少声明一个WRAP[]条目——正是它同时导致该二进制获得--wrap链接标志和-lcmocka。每个测试所需指令为PROGRAMS、SOURCE、INCLUDE、DEPEND、WRAP:
PROGRAMS{noinst}=crypto/foo/test_bar SOURCE[crypto/foo/test_bar]=crypto/foo/test_bar.c INCLUDE[crypto/foo/test_bar]=../../include ../../include/internal \ ../../crypto/foo DEPEND[crypto/foo/test_bar]=../../libcrypto.a WRAP[crypto/foo/test_bar]=BIO_socket BIO_listen BIO_closesocket要点说明:
PROGRAMS{noinst}将该二进制标记为不安装;INCLUDE[]列出测试所需的头文件目录,包括声明被测函数类型或函数本身的内部目录。cmocka 头文件路径会自动添加到每个含WRAP[]条目的目标,无需列出(Configure 中的逻辑会在目标含 cmocka 依赖时自动追加cmocka_includes并解析$(CMOCKA_LIBS));DEPEND[]链接相应的静态库:../../libcrypto.a,libssl 代码还需../../libssl.a;WRAP[]是要拦截的符号的空白分隔列表,可用行尾反斜杠跨多行;列出测试 mock 的依赖即可。
由于 recipe 按名称发现二进制,无需改动 Perl recipe;只要构建出新的test_*二进制,它就会在test_unit下运行。真实示例可对照 test/unit/build.info:test_bio_sock的WRAP[]横跨多行列出getsockopt setsockopt getsockname ioctl poll gethostbyname BIO_lookup BIO_socket BIO_listen ...,且全部目标都被包在IF[{- $config{target} =~ /^(?:linux|BSD)/ -}]条件块中;而 Windows(VC-)目标则改用UNIT_TEST[...]=cmocka detours声明,借助 cmocka 与 Microsoft Detours 实现拦截,这正是 README 中"仅 Linux 与 BSD 支持"在构建脚本层面的对应体现。
用 mkwraps.pl 生成 mock 桩
为长WRAP[]列表手写__wrap_*与expect_*样板既繁琐又易错,因此辅助脚本 util/mkwraps.pl 可以根据build.info声明生成初稿。它读取WRAP[<target>]列表,在该目标的INCLUDE[]目录下搜索每个函数的原型,并输出匹配的 wrap 函数与期望辅助函数。在项目头文件中找不到的函数(典型如read、socket等 libc/POSIX 函数)会回退到编译器的默认系统 include 路径查找,并以尖括号 include 形式输出。
$ ./util/mkwraps.pl --build-info test/unit/build.info \ --target crypto/foo/test_bar常用选项:
--mode wraps|expects|both:只输出__wrap_*函数、只输出expect_*辅助函数,或两者都输出(默认);--include DIR:在INCLUDE[]之外追加头文件搜索目录,可累积;--cc NAME:用于查询系统 include 路径的 C 编译器(默认$CC或cc);--no-system:项目头文件中缺失的函数不回退到编译器系统 include 目录;--output FILE:写入文件而非标准输出;--verbose:报告进度及每个原型在何处找到。
输出只是起点,不是成品测试。生成的 mock 会调用function_called()、检查每个参数并返回mock_type值,但真实行为仍需手工补充:出参的副作用、变参转发、条件will_return载荷、以及不透明类型的内部头文件使用。生成的#include行与参数检查也经常需要调整。把脚本当作跳过机械打字的工具,然后审查并编辑每个生成函数。
编写约定
单元测试遵循 OpenSSL 常规 C 编码风格(由 clang-format 强制),此处不重复。以下单元测试特有的约定有助于保持一致性与可维护性:
- 文件按 wraps、expectations、helpers、fixtures、tests、
main()的顺序组织,并配以上文所示短节注释; - 测试函数命名为
test_<area>_<behaviour>,让套件读起来像行为清单;任何从名字看不出 setup 或期望序列的测试,加一句简短注释; - 每个 mock 配一个对应的
expect_<name>辅助函数,所有对该 mock 的编程都经由它完成,而非在测试中内联expect_value/will_return——这样函数契约变化时只需改一处; - 每个测试聚焦单一行为,宁要几个小测试,不要一个带许多分支的大测试;
- 始终输出 TAP,始终重置会导致 teardown 期间产生意外 wrapped 调用的 fixture 状态。
小结
OpenSSL 的test/unit/是一套目标明确、边界清晰的单元测试体系:它以 cmocka 的队列式期望机制为编程模型,以 GNU/BSD 链接器的--wrap为依赖替换手段,以test/unit/build.info的WRAP[]声明为构建入口,以 test/recipes/02-test_unit.t 的按名发现机制为运行框架,并借助 util/mkwraps.pl 消除样板代码。阅读 test/unit/crypto/bio/test_bio_addr.c 与 test/unit/crypto/bio/test_bss_acpt.c 两个完整示例,再结合 test/unit/build.info 的声明方式,即可照此模式为自己的目标代码编写、接入并调试新的单元测试。
【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考