用 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 讓你可以把 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 上。
在 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 呼叫 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 是無號位元組(unsigned byte),也就是 Zig 用來表示字串中字元的方式。
[13:0] 表示一個以 null 結尾的陣列。13 是陣列的長度,由 Zig 在編譯時期計算出來。:0 表示陣列多了一個值為 0 的位元組,用來標示字串的結尾。關於 Zig 中以 null 結尾的字串機制的更多細節,請參閱我之前的文章。
*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 指標,它表示不知道這個指標是指向多個元素還是一個元素。可能是,也可能不是。我們也不知道它的長度(它不是有指標加長度的 slice,就只是一個指標)。而且就算有多個元素,我們也不知道它是不是以 null 結尾的。
好,這樣就合理多了。
在 C 語言中,指標就只是一個記憶體位址加上一個資料型別。一個 char* 型別的 C 指標,可能指向像 '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
編譯器錯誤中也包含了一個有助於呼叫 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 的第三個參數是一個指向以 null 結尾的字串的指標。要怎麼在 Zig 中表示它呢?
我一開始的想法是這樣做:
var cEncoded: [*:0]u8 = undefined;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);這看起來很合理。我知道 us_base64_encode 會用一個字串來填入 cEncoded,而 [*:0]u8 代表一個長度未知的、以 null 結尾的字串。但當我編譯時,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 型別卻不能是 null。這就是為什麼 Zig 拒絕讓我傳入前一次的嘗試。
拆解正確的型別 ?[*:0]u8,我發現它是:
- 一個以 null 結尾的位元組 slice(
:0]u8) - 長度未知(
[*) - 而且可能是 null(
?)
新的型別讓我可以編譯程式碼,但如果我試著列印 cEncoded 的值,得到的卻像是一個記憶體位址,而不是字串:
$ zig build run
input: hello, world!
output: u8@2b12a0 # << whoops, not what I expected
output size: 21為了把 cEncoded 轉回可列印的字串,我必須透過在程式碼中驗證其值非 null,來從 optional 變數中解包:
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() 函式中大部分都是在處理 Zig 與 C 之間的值轉換。
改善程式碼的一種方式是為 us_base64_encode 加上一個 Zig 包裝函式。這樣,我就可以把所有 Zig 與 C 互操作的邏輯封裝起來,包裝函式的呼叫者就不需要知道或在意我是在呼叫 C。
我的包裝函式應該長什麼樣子呢?
它應該接受任意的位元組並回傳一個以 null 結尾的字串,所以函式簽章大概會長這樣:
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 管理的 slice 中:
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(長度未知、以零結尾的 Zig slice),但我想回傳的是 [:0]u8(可感知長度、以 null 結尾的 Zig slice)。要怎麼把 C 風格的字串轉換為 Zig 的 slice 呢?
在我之前的文章中,我用以下流程把 C 字串轉換為 Zig 字串:
- 使用
std.mem.span從 C 字串建立一個 Zig slice。 - 使用
allocator.dupeZ把 slice 的內容複製到一個新配置的 Zig slice 中。
這個流程在這裡也行得通,但我在步驟 (1) 會做白工。std.mem.span 必須遍歷字串來找到 null 結尾字元。在這段程式碼中,我已經知道 null 結尾字元在哪裡,因為 us_base64_encode 已經把那個資訊存在 allocatedSize 參數中了。
相反地,我像這樣建立一個可感知長度的 Zig slice:
// 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 allocator,所以我調整了 base64Encode 包裝函式的語意,讓它接受一個 std.mem.Allocator 型別。
全部串起來
有了 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 所填入的輸出大小參數,它包含了 null 結尾字元。現在,我使用的是輸出字串的 .len 屬性,它不包含 null 結尾字元。
這個階段的完整範例在 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 授權使用。
隨機一篇部落格
留言
登入後參與討論