Using Zig to Unit Test a C Application

Michael Lynch

使用 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 在浏览器窗口中显示 Dell 启动画面的截图

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);
}

我将使用 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 代码可以直接使用翻译后的头文件。

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 代码推断数据类型时,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_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 参数中。

相反,我像这样为 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 代码的有用方式。


感谢 Ziggit 社区对本博文的帮助。摘自 uStreamer 的内容依据 GPLv3 许可证使用。

原文由 Michael Lynch 发布

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