Using Zig to Unit Test a C Application

Michael Lynch

ZigでCアプリケーションをユニットテストする

Zigは、新しく独立して開発された低水準プログラミング言語です。Cを現代的に再解釈した言語で、Cの性能を保ちつつ、この30年間で進化したツールや言語設計の成果を取り込もうとしています。

私がこれまで使ってきたどの言語よりも、ZigはCのコードを呼び出すのが簡単です。また、Zigはユニットテストを第一級の機能として扱っています。C言語にはない特徴です。

この2つの特徴が、興味深い可能性を生み出します。Zigを使えば、既存のCコードにユニットテストを追加できるのです。Cのコードやビルドの仕組みを書き換える必要はありません。

Zigで既存のCコードをテストする方法を示すため、普段自分が使っている実際のCアプリケーションにユニットテストを追加してみました。

題材となるCアプリケーション:uStreamer

私はこの3年間、オープンソースのKVM over IPであるTinyPilotに取り組んできました。TinyPilotはRaspberry Piを任意のコンピュータに接続し、そのコンピュータをリモートで操作できるようにするものです。

対象のコンピュータの画面を配信するために、TinyPilotはuStreamerを使っています。uStreamerはRaspberry Piのハードウェアに最適化された映像ストリーミング用のユーティリティです。

ブラウザウィンドウに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でエンコードする入力データです。
  • sizeは、dataバッファの長さ(バイト数)です。
  • encodedは、us_base64_encodeがbase64エンコードされた文字列を格納する出力バッファへのポインタです。
    • us_base64_encodeが出力用のメモリを確保し、使い終わったら呼び出し側が解放する責任を負います。
    • 厳密には、us_base64_encodeは呼び出し側がencoded用のバッファを確保することも許容しますが、ここでは簡単のため、その機能は無視します。
  • allocatedは、us_base64_encodeencodedに確保したバイト数を書き込むポインタです。

この関数を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を見てみると、すべてのエラーは1つの関数、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の余分な1バイトがあり、文字列の終端を示すことを意味します。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は単なるCのポインタです。そこに複数の要素があるのかどうか分からないことを表しています。要素が複数あるかもしれないし、ないかもしれません。長さも分かりません(ポインタと長さを持つスライスではなく、単なるポインタです)。また、要素が複数あるとしても、たとえばヌル終端されているのかどうかも分かりません。

redditの-/u/slimsagさんによる説明

なるほど、これならよく分かります。

Cでは、ポインタは単なるメモリアドレスとデータ型の組み合わせです。char*というCの型は、'A'のような1文字を指すこともあれば、"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);
                                                     ^~~~~~~~~

前進です!

まだコンパイルは通りませんが、今度は1つ目ではなく3つ目のパラメータについてZigが文句を言っています。少なくとも、最初の2つのパラメータは期待どおりの型を渡せたということです。

出力パラメータを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 {

これはCの関数をZigに変換したシグネチャで、関数を呼び出すために渡すべき型を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の3つ目のパラメータは、ヌル終端文字列へのポインタだということは分かっています。これをZigではどう表現すればよいのでしょうか。

最初に思いついたのは次のような方法でした。

var cEncoded: [*:0]u8 = undefined;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

一見理にかなっているように思えました。us_base64_encodecEncodedに文字列を格納することは分かっていますし、[*:0]u8は長さが不明なヌル終端文字列を表します。しかしコンパイルすると、Zigに拒否されました。

error: expected type '[*c][*c]u8', found '*[*:0]u8'

お手上げになったので、ZigのディスカッションフォーラムであるZiggitで助けを求めました。1時間もしないうちに、別のユーザーが解決策を示してくれました

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を分解すると、次のようになります。

新しい型にしたことでコンパイルは通るようになりましたが、cEncodedの値を表示しようとすると、文字列ではなくメモリアドレスのようなものが表示されました。

$ zig build run
input:       hello, world!
output:      u8@2b12a0      # << whoops, not what I expected
output size: 21

cEncodedを印字可能な文字列に戻すには、値が非nullであることをコード上で確認して、オプショナル型からアンラップする必要があります。

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(長さが不明でゼロ終端の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ラッパーを使えば、次の2行に簡潔になります。

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の実装が正しいことを検証するためのユニットテストを書き始める準備ができました。

最初に、ユニットテストがlibcとuStreamerのCソースファイルにアクセスできるように、build.zigに少し手を加える必要があります。

// 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

この2行の順序を入れ替えてみます。

    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」を使用して翻訳されました。