Using Zig to Unit Test a C Application

Michael Lynch

使用 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,这是一款针对树莓派硬件优化的视频推流工具。

浏览器窗口中显示 Dell 启动画面的 TinyPilot 截图

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.git

uStreamer 中最简单的 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 编码方案进行编码的输入数据。
  • sizedata 缓冲区的长度(以字节为单位)。
  • 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 代码可以直接使用翻译后的头文件。

https://ziglang.org/documentation/0.11.0/#C-Pointers

我没看懂这段文档,它似乎是在警告不要使用 C 指针,而不是在解释它是什么。

又用 Kagi 搜了一番后,我在 reddit 上找到了一个更易懂的解释:

[*c]T 其实就是指向类型 T 的 C 指针,它表示不知道这个指针是指向单个元素还是多个元素。可能是一个,也可能是多个。我们也不知道它的长度(它不是包含指针和长度的切片,就只是一个指针)。而且即使有多个元素,我们也不知道它是否是空终止的,等等。

-/u/slimsag on reddit

好吧,这样就好理解多了。

在 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_encodecEncoded 指针分配了内存。调用者要么负责释放这块内存,要么将这一责任转交给自己的调用者。

通常情况下,函数声明由调用者负责释放输出值是没问题的,但这种情况有点特殊。这不是普通的由 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 字符串:

  1. 使用 std.mem.span 从 C 字符串创建一个 Zig 切片。
  2. 使用 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 做任何改动就能照常工作。


感谢 Ziggit 社区对本文的帮助。文中引用的 uStreamer 代码片段基于 GPLv3 许可证使用。

本文章由 muse-spark-1.2-contributor 进行翻译

评论