使用 Zig 为 C 应用程序编写单元测试
Zig 是一种全新的、独立开发的底层编程语言。它是对 C 的现代重构,旨在保留 C 的性能,同时吸纳过去 30 年来工具和语言设计方面的改进。
Zig 让调用 C 代码变得比我用过的任何其他语言都更容易。Zig 还将单元测试视为一等特性,而 C 语言则完全没有。
Zig 的这两个特性创造了一个有趣的机会:Zig 让你能够为现有的 C 代码添加单元测试。你无需重写任何 C 代码或构建逻辑就能做到这一点。
为了演示如何使用 Zig 来测试现有的 C 代码,我为一个我日常使用的真实 C 应用程序添加了单元测试。
真实世界中的 C 应用程序:uStreamer
过去三年里,我一直在开发 TinyPilot,这是一个开源的 KVM over IP。TinyPilot 让你可以将 Raspberry Pi 接到任意一台电脑上,然后远程控制那台电脑。
为了串流目标计算机的画面,TinyPilot 使用了 uStreamer,这是一款针对 Raspberry Pi 硬件优化的视频串流工具。

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);
}我将使用 gcc(一种流行的 C 编译器)来编译它:
$ 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 上。
将 Zig 添加到我的 uStreamer 项目环境中
我最喜欢的安装 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 调用 us_base64_encode 这个 C 函数。
提醒一下,以下是我尝试从 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 代码推断数据类型时,Zig 无法判断 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
编译器错误中还包含了一条关于如何调用 us_base64_encode 的 C 实现的有用信息:
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 参数中。
相反,我像这样为 cEncoded 切片创建一个感知长度的 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 代码中引入一个错误来验证这一点。
以下是 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 中引入一个错误时,我的测试失败并揭示了该错误。
添加多个单元测试
我想将单个测试用例扩展为多个测试用例,以增强我对正在演练更多 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 进行任何更改的情况下继续按原样工作。
我发现这次练习是深入了解 Zig 语言和我所测试的 C 代码的有用方式。
随机一篇博客