使用 Zig 为 C 程序编写单元测试
原文由 Michael Lynch 于 发布,订阅该博客
Zig 是一门全新、独立开发的底层编程语言。它可以看作是对 C 的现代重构,在保留 C 语言性能的同时,吸收了过去 30 年来工具链与语言设计上的进步。
Zig 在调用 C 代码方面的易用性超过了我用过的任何其他语言。同时,Zig 将单元测试视为一等特性,而 C 语言显然并非如此。
Zig 的这两个特性带来了一个有趣的可能性:你可以用 Zig 为现有的 C 代码添加单元测试,而且无需重写任何 C 代码或构建逻辑。
为了演示如何用 Zig 测试现有的 C 代码,我为自己日常使用的一款真实 C 应用添加了单元测试。
真实世界的 C 应用:uStreamer
过去三年来,我一直在开发 TinyPilot,这是一款开源的 KVM over IP 设备。TinyPilot 可以让你把树莓派接到任意一台电脑上,然后远程控制这台电脑。
为了传输目标电脑的画面,TinyPilot 使用了 uStreamer,这是一款针对树莓派硬件优化的视频推流工具。

TinyPilot 使用 C 语言编写的 uStreamer 应用来传输视频
我使用 uStreamer 已有数年,但一直觉得它的代码库难以入手。它是用 C 实现的,而且没有任何自动化测试。
我最擅长通过动手折腾代码来学习,因此通过 Zig 来调用和测试 uStreamer 的 C 代码,对我而言是同时深入了解 uStreamer 和 Zig 的好方法。
获取 uStreamer 源码
首先,我来获取 uStreamer 的源代码。截至本文写作时,最新版本是 v5.45,因此我就以这个版本为例:
USTREAMER_VERSION='v5.45'
git clone \
--branch "${USTREAMER_VERSION}" \
https://github.com/pikvm/ustreamer.gituStreamer 中最简单的 C 函数是什么?
在这次实践中,挑战在于使用 Zig,所以我希望 C 相关的部分越简单越好。
我想在 uStreamer 的 C 代码中找到一个极其简单的函数——给它一些输入,就能得到易于检查的输出。
浏览文件名时,我注意到了 base64.c。这个文件看起来很有希望。我知道 base64 是一种将任意数据编码为可打印字符串的方案。
例如,如果从 /dev/random 读取 10 个字节并输出到终端,会得到一些无法打印的字节:
$ head -c 10 /dev/random > /tmp/output && cat /tmp/output
V�1A�����b如果把这些数据用 base64 编码,就能得到干净、可打印的字符:
$ base64 < /tmp/output
Vo8xQbWmnsLQYg==下面是 uStreamer 中 base64 函数的签名:
// src/libs/base64.h
void us_base64_encode(const uint8_t *data, size_t size, char **encoded, size_t *allocated);通过查看 base64.c 中该函数的实现,我推断出 us_base64_encode 的语义如下:
data是要用 base64 编码方案进行编码的输入数据。size是data缓冲区的长度(以字节为单位)。encoded是指向输出缓冲区的指针,us_base64_encode会将 base64 编码后的字符串存入其中。us_base64_encode会为输出分配内存,调用者在用完后负责释放。- 严格来说,
us_base64_encode允许调用者自行 为encoded分配缓冲区,但为简单起见,这里忽略这一用法。
allocated是指向一个指针,us_base64_encode会将它所分配给encoded的字节数写入其中。
下面是一个从 C 调用该函数的简单测试程序:
// src/test.c
#include <stdio.h>
#include "libs/base64.h"
void main(void) {
char *input = "hello, world!";
char *encoded = NULL;
size_t encoded_bytes = 0;
us_base64_encode((uint8_t *)input, strlen(input), &encoded, &encoded_bytes);
printf("input: %s\n", input);
printf("output: %s\n", encoded);
printf("output bytes: %lu\n", encoded_bytes);
free(encoded);
}我将用流行的 C 编译器 gcc 来编译它:
$ gcc src/test.c src/libs/base64.c -o /tmp/b64test
In file included from src/libs/base64.h:31,
from src/test.c:3:
src/libs/tools.h: In function ‘us_signum_to_string’:
src/libs/tools.h:194:34: warning: implicit declaration of function ‘sigabbrev_np’ [-Wimplicit-function-declaration]
194 | const char *const name = sigabbrev_np(signum);嗯,代码能编译通过,但关于 uStreamer 引入的 tools.h 头文件,编译器报出了一大堆警告。
如果查看 src/libs/tools.h,会发现所有报错都集中在一个函数上:us_signum_to_string。我试试直接把这个函数注释掉,以清除这些无关的警告。
/*
DEBUG: Temporarily delete this function to get the build working again.
INLINE char *us_signum_to_string(int signum) {
...
return buf;
}
*/去掉这个碍事的 us_signum_to_string 函数后,我再试一次编译:
$ gcc src/test.c src/libs/base64.c -o /tmp/b64test && /tmp/b64test
input: hello, world!
output: aGVsbG8sIHdvcmxkIQ==
output bytes: 21太好了,没有编译警告了。
如果我要编译整个 uStreamer,就必须想办法让 us_signum_to_string 正常编译。但在这次练习中,我只是要从 Zig 调用 us_base64_encode,所以并不需要 us_signum_to_string。
如果将 test.c 程序的输出与系统自带的 base64 工具的输出对比,就能验证结果是正确的:
$ printf 'hello, world!' | base64
aGVsbG8sIHdvcmxkIQ==此阶段的完整示例 已在 GitHub 上。
在 uStreamer 项目环境中添加 Zig
我最喜欢的 Zig 安装方式是 通过 Nix 安装,因为这样可以轻松切换 Zig 版本。当然,你也可以用自己喜欢的任何方式安装 Zig。
我在项目中添加了如下的 flake.nix 文件,它会将 Zig 0.11.0 引入到我的环境中:
{
description = "Dev environment for zig-c-simple";
inputs = {
flake-utils.url = "github:numtide/flake-utils";
# 0.11.0
zig-nixpkgs.url = "github:NixOS/nixpkgs/46688f8eb5cd6f1298d873d4d2b9cf245e09e88e";
};
outputs = { self, flake-utils, zig-nixpkgs }@inputs :
flake-utils.lib.eachDefaultSystem (system:
let
zig-nixpkgs = inputs.zig-nixpkgs.legacyPackages.${system};
in
{
devShells.default = zig-nixpkgs.mkShell {
packages = [
zig-nixpkgs.zig
];
shellHook = ''
echo "zig" "$(zig version)"
'';
};
});
}接下来,我运行 nix develop,就可以看到 Zig 0.11.0 已在项目环境中可用:
# There's a weird quirk of Nix flakes that they have to be added to your git
# repo.
$ git add flake.nix
$ nix develop
zig 0.11.0创建 Zig 可执行程序
Zig 编译器的 init-exe 会创建一个样板 Zig 应用,因此我用它在 uStreamer 源码树中创建一个简单的 Zig 程序:
$ zig init-exe
info: Created build.zig
info: Created src/main.zig
info: Next, try `zig build --help` or `zig build run`如果尝试编译并运行这个样板 Zig 应用,可以看到一切正常:
$ zig build run
All your codebase are belong to us.
Run `zig build test` to run the tests.我要调用的 uStreamer C 文件依赖于 C 标准库,因此我需要对 build.zig 文件做一点小调整,以链接该库。顺便,我也把样板程序的二进制名称改成 base64-encoder:
const exe = b.addExecutable(.{
.name = "base64-encoder", // Change binary name.
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
});
exe.linkLibC(); // Link against C standard library.
exe.addIncludePath(.{ .path = "src" });从 Zig 调用 uStreamer 代码
现在,我想从 Zig 中调用 C 函数 us_base64_encode。
提醒一下,下面是我试图从 Zig 调用的 C 函数,之前已在上文中介绍过:
// src/libs/base64.h
void us_base64_encode(const uint8_t *data, size_t size, char **encoded, size_t *allocated);弄清楚如何在 C 类型和 Zig 类型之间转换,结果成了整个过程中最难的部分,毕竟我还是个 Zig 新手。
这是我的第一次尝试:
// src/main.zig
const ustreamer = @cImport({
@cInclude("libs/base64.c");
});
pub fn main() !void {
const input = "hello, world!";
var cEncoded: *u8 = undefined;
var allocatedSize: usize = 0;
// WRONG: This doesn't compile.
ustreamer.us_base64_encode(&input, input.len, &cEncoded, &allocatedSize);
}结果得到了这样一个编译错误:
$ zig build run
zig build-exe b64 Debug native: error: the following command failed with 1 compilation errors:
...
src/main.zig:17:32: error: expected type '[*c]const u8', found '*const *const [13:0]u8'
ustreamer.us_base64_encode(&input, input.len, &cEncoded, &allocatedSize);
^~~~~~
src/main.zig:17:32: note: pointer type child '*const [13:0]u8' cannot cast into pointer type child 'u8'
/home/mike/ustreamer/zig-cache/o/9599bf4c636d23e50eddd1a55dd088ff/cimport.zig:1796:43: note: parameter type declared here
pub export fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {起初我很难理解这个错误,因为其中很多内容都很陌生。
上面编译错误中最关键的一句是 error: expected type '[*c]const u8', found '*const *const [13:0]u8'。它的意思是,我传入了一个 *const *const [13:0]u8 类型的参数,但 Zig 要求我传入 [*c]const u8。
这是什么意思呢?
理解我使用的类型
根据 Zig 编译器的提示,我传入的参数类型是 '*const *const [13:0]u8。要理解它的含义,我们从右往左看:
u8 是无符号字节,也是 Zig 表示字符串中字符的方式。
[13:0] 表示一个以空字符结尾的数组。其中 13 是数组的长度,由 Zig 在编译时计算得出;:0 表示该数组末尾多了一个值为 0 的字节,用来标记字符串的结束。关于 Zig 中空终止字符串机制的更多细节,请参阅我的上一篇文章。
*const 表示指向常量的指针。指针是内存中的一个地址,而 const 意味着后续代码不能重新赋值该变量。
*const *const 表示指向常量指针的常量指针。换句话说,input 是指向字符串的常量指针,因此 &input 就是指向常量指针的常量指针。
将 Zig 类型转换为 C 类型
好了,现在我明白 Zig 是如何看待我传入的字符串的。那么 Zig 希望我为 input 传入什么类型呢?
expected type '[*c]const u8'[*c] 到底是什么意思?
这个问题出乎意料地难搞清。我最终是从几个不同的来源拼凑出了答案。
以下是官方 Zig 文档的说法:
C 指针
应尽可能避免使用该类型。使用 C 指针的唯一合理理由是在翻译 C 代码时自动生成的代码中。
在导入 C 头文件时,指针应该被翻译为单项指针 (*T) 还是多项指针 ([*]T) 是含糊不清的。C 指针是一种折中方案,以便 Zig 代码可以直接使用翻译后的头文件。
我没看懂这段文档,它似乎是在警告不要使用 C 指针,而不是在解释它是什么。
又用 Kagi 搜了一番后,我在 reddit 上找到了一个更易懂的解释:
[*c]T其实就是指向类型 T 的 C 指针,它表示不知道这个指针是指向单个元素还是多个元素。可能是一个,也可能是多个。我们也不知道它的长度(它不是包含指针和长度的切片,就只是一个指针)。而且即使有多个元素,我们也不知道它是否是空终止的,等等。
好吧,这样就好理解多了。
在 C 语言中,指针只是一个内存地址加上一个数据类型。C 类型中的 char* 可以指向像 'A' 这样的单个字符,也可以指向像 "ABCD" 这样序列中的第一个字符。
在 Zig 中,指向数组的指针与指向单个元素的指针是不同的类型。当 Zig 必须从 C 代码推断数据类型时,它无法判断 C 代码指的是单个元素还是数组,因此 C 指针类型([*c]T)就是 Zig 在说:“我也不知道,这是从 C 那里拿来的。”
通过反复试错,我发现 Zig 希望我通过 input.ptr 来获取指向 input 的指针,而不是使用取地址运算符 &。
下面这段 Zig 代码展示了 .ptr 和 & 之间的区别:
const input = "hello, world!";
std.debug.print("input is type {s}\n", .{@typeName(@TypeOf(input))});
std.debug.print("&input is type {s}\n", .{@typeName(@TypeOf(&input))});
std.debug.print("input.ptr is type {s}\n", .{@typeName(@TypeOf(input.ptr))});input is type *const [13:0]u8
&input is type *const *const [13:0]u8
input.ptr is type [*]const u8回想一下,Zig 希望我传给 us_base64_encode 的参数类型是 [*c]const u8,看起来它可以将 [*]const u8 转换为该类型。
好吧,让我再试一次调用 us_base64_encode:
const input = "hello, world!";
var cEncoded: *u8 = undefined;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);结果得到:
$ zig build run
zig build-exe b64 Debug native: error: the following command failed with 1 compilation errors:
...
src/main.zig:12:54: error: expected type '[*c][*c]u8', found '**u8'
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
^~~~~~~~~有进展了!
代码依然编译不过,但 Zig 现在抱怨的是第三个参数,而不是第一个。至少这说明前两个参数的类型已经对了。
将输出参数翻译成 Zig
编译错误中还包含了一条对调用 C 实现的 us_base64_encode 很有帮助的信息:
pub export fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {这就是被翻译成 Zig 后的 C 函数签名,因此 Zig 实际上是在准确地告诉我调用该函数需要传入哪些类型。
或者,我也可以使用 zig translate-c 工具将这个 C 函数签名翻译成 Zig。效果与上面的编译错误基本相同,但它会保留原始的参数名,而编译错误则会在参数名前加上 arg_ 前缀。
# We add --library c to let Zig know the code depends on libc.
$ zig translate-c src/libs/base64.h --library c | grep us_base64
pub extern fn us_base64_encode(data: [*c]const u8, size: usize, encoded: [*c][*c]u8, allocated: [*c]usize) void;经过更多的试错,我最终摸索出了从 Zig 调用 us_base64_encode 的正确写法:
const input = "hello, world!";
var cEncoded: [*c]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);终于编译成功了!
能比 C 指针做得更好吗?
回想一下 Zig 文档中关于 C 指针的说法:
使用 C 指针的唯一合理理由是在自动生成的代码中……
我是在手写这些代码,所以大概不应该使用这种为自动生成代码保留的类型。
我知道 us_base64_encode 的第三个参数是指向空终止字符串的指针。在 Zig 中该如何表示它呢?
我的第一反应是这样写:
var cEncoded: [*:0]u8 = undefined;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);这看起来很合理。我知道 us_base64_encode 会用一个字符串来填充 cEncoded,而 [*:0]u8 表示一个长度未知的空终止字符串。但编译时,Zig 却不同意:
error: expected type '[*c][*c]u8', found '*[*:0]u8'我卡住了,于是到 Zig 讨论论坛 Ziggit 上求助。不到一小时,就有另一位用户给我提供了个解决方案:
var cEncoded: ?[*:0]u8 = null;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);问题在于,在 C 中,char** 类型可以为 null,而 Zig 中的 [*:0]u8 类型不能为空。这就是为什么 Zig 拒绝让我传入之前的写法。
拆解一下正确的类型 ?[*:0]u8,可以看到它是:
- 以空字符结尾的字节切片(
:0]u8) - 长度未知(
[*) - 可能为 null(
?)
新的类型让代码得以编译,但如果我尝试打印 cEncoded 的值,得到的似乎是一个内存地址,而不是字符串:
$ zig build run
input: hello, world!
output: u8@2b12a0 # << whoops, not what I expected
output size: 21为了将 cEncoded 转换回可打印的字符串,我必须通过代码验证其值非空,从而将其从可选类型中解包:
var cEncoded: ?[*:0]u8 = null;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;
...
std.debug.print("output: {s}\n", .{output});然后就能打印出正确的结果了:
$ zig build run
input: hello, world!
output: aGVsbG8sIHdvcmxkIQ==
output size: 21完成从 Zig 对 C 的调用
至此,我已经有了从 Zig 调用 C 函数 us_base64_encode 的完整可用代码。以下是完整的 src/main.zig 文件:
// src/main.zig
const std = @import("std");
// Import the base64 implementation from uStreamer's C source file.
const ustreamer = @cImport({
@cInclude("libs/base64.c");
});
pub fn main() !void {
// Create a standard Zig string.
const input = "hello, world!";
// Create variables to store the ouput parameters of us_base64_encode.
var cEncoded: ?[*:0]u8 = null;
var allocatedSize: usize = 0;
// Call the uStreamer C function from Zig.
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
// Get the output as a non-optional type.
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;
// Free the memory that the C function allocated when this function exits.
defer std.c.free(cEncoded);
// Print the input and output of the base64 encode operation.
std.debug.print("input: {s}\n", .{input});
std.debug.print("output: {s}\n", .{output});
std.debug.print("output size: {d}\n", .{allocatedSize});
}$ zig build run
input: hello, world!
output: aGVsbG8sIHdvcmxkIQ==
output size: 21太好了,成功了!而且结果与我上文中的 C 实现完全一致。
此阶段的完整示例 已在 GitHub 上。
为原生 C 实现创建 Zig 封装
到目前为止,我已经可以从 Zig 成功调用 C 函数 us_base64_encode,但代码有点混乱。我的 main() 函数中大部分代码都在处理与 C 代码之间的值转换。
改进代码的一种方法是为 us_base64_encode 添加一个 Zig 封装函数。这样,我就可以把所有 Zig 与 C 的互操作逻辑封装起来,封装函数的调用者就无需知道或关心底层是在调用 C。
我的封装函数应该长什么样呢?
它应该接受任意字节并返回一个空终止字符串,因此函数签名大概应该是这样:
fn base64Encode(data: []const u8) [:0]u8 {...}基于上文 main() 函数中的实现,我已经有了封装函数的开头几行:
fn base64Encode(data: []const u8) [:0]u8 {
var cEncoded: ?[*:0]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(data.ptr, data.len, &cEncoded, &allocatedSize);
// TODO: Complete the implementation.谁来负责释放 C 分配的内存?
还有一个我尚未解决的问题。us_base64_encode 向 cEncoded 指针分配了内存。调用者要么负责释放这块内存,要么将这一责任转交给自己的调用者。
通常情况下,函数声明由调用者负责释放输出值是没问题的,但这种情况有点特殊。这不是普通的由 Zig 分配的内存缓冲区——而是由 C 分配的缓冲区,需要用专门的释放函数(std.c.free)来释放。
我想把 C 实现的细节抽象掉,因此调用者不应该需要使用特定于 C 的内存释放函数。
这就明确了我完成 Zig 封装实现需要做什么。我使用 defer std.c.free 来释放 C 分配的内存,然后需要将其内容拷贝到一个由 Zig 管理的切片中:
fn base64Encode(data: []const u8) ![:0]u8 {
var cEncodedOptional: ?[*:0]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(data.ptr, data.len, &cEncodedOptional, &allocatedSize);
const cEncoded: [*:0]u8 = cEncodedOptional orelse return error.UnexpectedNull;
// Get the output as a non-optional type.
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;
// Free the C-allocated memory buffer before exiting the function.
defer std.c.free(cEncoded);
// TODO: Copy the contents of cEncoded into a [:0]u8 buffer.
}将 C 字符串转换为 Zig 字符串
此时,我得到的字符串类型是 [*:0]u8(长度未知、以 0 结尾的 Zig 切片),但我想返回的是 [:0]u8(带长度信息、以空字符结尾的 Zig 切片)。如何将 C 风格的字符串转换为 Zig 切片呢?
在我的上一篇文章中,我通过以下步骤将 C 字符串转换为 Zig 字符串:
- 使用
std.mem.span从 C 字符串创建一个 Zig 切片。 - 使用
allocator.dupeZ将切片内容拷贝到新分配的 Zig 切片中。
这个流程在这里也可行,但在第 (1) 步会做无用功。std.mem.span 必须遍历字符串来寻找空终止符。而在这段代码中,我已经知道空终止符的位置,因为 us_base64_encode 已将该信息存储在 allocatedSize 参数中。
因此,我像这样直接创建一个带长度信息的 Zig 切片:
// The allocatedSize includes the null terminator, so subtract 1 to get the
// number of non-null characters in the string.
const cEncodedLength = allocatedSize - 1;
// Convert cEncoded (unknown length slice) to a length-aware slice.
const outputLengthAware: [:0] = cEncoded[0..cEncodedLength :0];至此,我可以完成封装函数的实现了:
fn base64Encode(allocator: std.mem.Allocator, data: []const u8) ![:0]u8 {
var cEncoded: [*c]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(data.ptr, data.len, &cEncoded, &allocatedSize);
// Get the output as a non-optional type.
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;
// Free the C-allocated memory buffer before exiting the function.
defer std.c.free(cEncoded);
// The allocatedSize includes the null terminator, so subtract 1 to get the
// number of non-null characters in the string.
const cEncodedLength = allocatedSize - 1;
return allocator.dupeZ(u8, cEncoded[0..cEncodedLength :0]);
}为了调用 dupeZ,我需要一个 Zig 分配器,因此我调整了 base64Encode 封装函数的语义,让它接受一个 std.mem.Allocator 类型的参数。
整合起来
有了这个 Zig 封装,现在从 Zig 调用 C 函数 us_base64_encode 就变得非常简单了。
回想一下,我之前的代码是这样的:
const input = "hello, world!";
var cEncoded: ?[*:0]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;
defer std.c.free(cEncoded);有了 Zig 封装后,逻辑简化为两行:
const output = try base64Encode(allocator, "hello, world!");
defer allocator.free(output);以下是完整示例:
const std = @import("std");
// Import the base64 implementation from uStreamer's C source file.
const ustreamer = @cImport({
@cInclude("libs/base64.c");
});
fn base64Encode(allocator: std.mem.Allocator, data: []const u8) ![:0]u8 {
var cEncodedOptional: ?[*:0]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(data.ptr, data.len, &cEncodedOptional, &allocatedSize);
const cEncoded: [*:0]u8 = cEncodedOptional orelse return error.UnexpectedNull;
defer std.c.free(cEncodedOptional);
const cEncodedLength = allocatedSize - 1;
return allocator.dupeZ(u8, cEncoded[0..cEncodedLength :0]);
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
defer _ = gpa.deinit();
const input = "hello, world!";
const output = try base64Encode(allocator, input);
defer allocator.free(output);
// Print the input and output of the base64 encode operation.
std.debug.print("input: {s}\n", .{input});
std.debug.print("output: {s}\n", .{output});
std.debug.print("output size: {d}\n", .{output.len});
}$ zig build run
input: hello, world!
output: aGVsbG8sIHdvcmxkIQ==
output size: 20输出大小现在是 20 而不是 21,因为底层数据类型变了。之前我打印的是 us_base64_encode 所填充的输出大小参数,其中包含了空终止符。现在,我使用的是输出字符串的 .len 属性,它不包含空终止符。
此阶段的完整示例 已在 GitHub 上。
编写第一个单元测试
现在我已经可以通过便捷的 Zig 封装来调用 C 函数 us_base64_encode,接下来就可以开始编写单元测试来验证 C 实现的正确性了。
首先,我需要对 build.zig 文件做两处小调整,以便单元测试能够访问 libc 和 uStreamer 的 C 源文件:
// build.zig
const unit_tests = b.addTest(.{
.root_source_file = .{ .path = "src/main.zig" },
.target = target,
.optimize = optimize,
});
unit_tests.linkLibC(); // Link against libc.
unit_tests.addIncludePath(.{ .path = "src" }); // Search src path for includes.通过编写 Zig 封装函数,我已经完成了最艰难的部分,因此编写第一个单元测试就很直接了:
// src/main.zig
test "encode simple string as base64" {
const allocator = std.testing.allocator;
const actual = try base64Encode(allocator, "hello, world!");
defer allocator.free(actual);
try std.testing.expectEqualStrings("aGVsbG8sIHdvcmxkIQ==", actual);
}zig build test 命令会运行我的单元测试:
$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 1/1 tests passed
test success
└─ run test 1 passed 1ms MaxRSS:1M
└─ zig test Debug native success 2s MaxRSS:211M成功了!我的第一个单元测试已经正常运行,并真正测试到了 C 代码。
此阶段的完整示例 已在 GitHub 上。
检查是否存在假阳性
我的单元测试通过了,但我想确保测试是真正执行了 C 代码,而不仅仅是返回了假阳性。我可以通过故意在 C 代码中引入一个 bug 来验证这一点。
这是来自 base64.c 实现的一段代码片段:
# define OCTET(_name) unsigned _name = (data_index < size ? (uint8_t)data[data_index++] : 0)
OCTET(octet_a);
OCTET(octet_b);
OCTET(octet_c);
# undef OCTET我来试着交换这两行的顺序:
OCTET(octet_a);
OCTET(octet_c); // I've swapped these
OCTET(octet_b); // two lines.这是我在篡改后重新对该 C 函数运行单元测试的结果:
$ zig build test --summary all
run test: error: 'test.encode simple string as base64' failed: ====== expected this output: =========
aGVsbG8sIHdvcmxkIQ==␃
======== instead found this: =========
aGxlbCxvIG93cmRsIQ==␃太棒了,测试生效了!
当我在 us_base64_encode 中引入 bug 后,测试失败并暴露了这个 bug。
添加多个单元测试
我想把单个测试用例扩展为多个测试用例,以更充分地覆盖 C 函数的逻辑,增强信心。
我的第一个单元测试中有一半代码是围绕内存管理的样板代码,因此我不想为每个测试都重复这些代码。我编写了一个工具函数来封装这些样板:
fn testBase64Encode(
input: []const u8,
expected: [:0]const u8,
) !void {
const allocator = std.testing.allocator;
const actual = try base64Encode(allocator, input);
defer allocator.free(actual);
try std.testing.expectEqualStrings(expected, actual);
}有了这个测试工具函数,我就可以轻松地添加新测试了:
test "encode strings as base64" {
try testBase64Encode("", "");
try testBase64Encode("h", "aA==");
try testBase64Encode("he", "aGU=");
try testBase64Encode("hel", "aGVs");
try testBase64Encode("hell", "aGVsbA==");
try testBase64Encode("hello, world!", "aGVsbG8sIHdvcmxkIQ==");
}
test "encode raw bytes as base64" {
try testBase64Encode(&[_]u8{0}, "AA==");
try testBase64Encode(&[_]u8{ 0, 0 }, "AAA=");
try testBase64Encode(&[_]u8{ 0, 0, 0 }, "AAAA");
try testBase64Encode(&[_]u8{255}, "/w==");
try testBase64Encode(&[_]u8{ 255, 255 }, "//8=");
try testBase64Encode(&[_]u8{ 255, 255, 255 }, "////");
}$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 2/2 tests passed
test success
└─ run test 2 passed 2ms MaxRSS:2M
└─ zig test Debug native success 2s MaxRSS:195M此阶段的完整示例 已在 GitHub 上。
总结
得益于 Zig 与 C 出色的互操作性,我们可以在不修改任何 C 代码或构建流程的情况下,为现有的 C 应用添加单元测试。
在我展示的示例中,C 代码完全不知道 Zig 的存在,并且无需对其现有的 Makefile 做任何改动就能照常工作。
随机一篇博客
评论
登录后参与讨论