拓冰建站拓冰建站
首页 / 资讯中心 / 正文

OpenSSL 单元测试实战指南:基于 cmocka 与 --wrap 的隔离测试体系(test/unit)

OpenSSL 单元测试实战指南基于 cmocka 与 --wrap 的隔离测试体系test/unit【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/opensslOpenSSL 作为一个承载 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拼装为-Ldir -lcmocka形式的cmocka_libs同时源码中还有一条关键逻辑——只要某个目标声明了WRAP[]条目就隐式地为其附加 cmocka 依赖WRAP implies cmockacmocka 的头文件搜索路径也会自动注入到这类目标的编译参数中。在不支持--wrap的平台上enable-unit-tests会在配置阶段被静默关闭其余构建流程照常进行。运行方式一随全套测试一起跑整个单元测试套件作为正常测试目标的一部分运行$ make test运行方式二单独运行 test_unit单元测试被收敛在单一 recipe之下因此也可以单独执行$ make test TESTStest_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 输出并运行整个 groupint 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); }断言来自 cmockaassert_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,--wrapfoo链接时所有对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); }这些副作用应保持最小化只局限于被测函数真正观察到的部分——目标是复现真实函数的契约而不是重新实现它。编程 mockexpectations与其在每个测试里散落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/teardowncmocka_run_group_tests的最后两个参数适合放置每个测试共享的一次性工作例如初始化跨文件使用的静态 fixture。test/unit/crypto/bio/test_bss_acpt.c 中的group_setup就负责构建fake_sink_methodgroup_teardown负责BIO_meth_free释放它。当 fixture 与被 wrap 函数交互时有两条实战注意事项teardown 自身可能触发被 wrap 的调用。如果释放被测对象会调用某个被 wrap 函数例如关闭 socket应在释放前把相关字段重置为安全哨兵值以免产生意外的 mock 调用或者为它编程期望。常见模式是一个小的reset_for_teardown()辅助函数在留下此类状态的测试末尾调用。该模式在test_bss_acpt.c中被广泛使用。用真实的最小 fake 驱动对象往往比什么都 mock 更干净。例如构造一个小型 fakeBIO_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、WRAPPROGRAMS{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.alibssl 代码还需../../libssl.aWRAP[]是要拦截的符号的空白分隔列表可用行尾反斜杠跨多行列出测试 mock 的依赖即可。由于 recipe 按名称发现二进制无需改动 Perl recipe只要构建出新的test_*二进制它就会在test_unit下运行。真实示例可对照 test/unit/build.infotest_bio_sock的WRAP[]横跨多行列出getsockopt setsockopt getsockname ioctl poll gethostbyname BIO_lookup BIO_socket BIO_listen ...且全部目标都被包在IF[{- $config{target} ~ /^(?:linux|BSD)/ -}]条件块中而 WindowsVC-目标则改用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),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门