Using Zig to Unit Test a C Application

Michael Lynch

Zigを使ってCアプリケーションを単体テストする

原文は Michael Lynch により に公開されました。 このブログを購読する

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

Zigは、私が使ったことのあるどの言語よりもCのコードを呼び出しやすくしてくれます。また、Zigは単体テストを第一級の機能として扱っていますが、C言語は決してそうではありません。

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

既存のCコードのテストにZigをどう使うかを示すため、日常的に使っている実際のCアプリケーションに単体テストを追加してみました。

実際のCアプリケーション:uStreamer

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

ターゲットコンピューターの画面をストリーミングするために、TinyPilotはRaspberry Piのハードウェアに最適化された動画ストリーミングユーティリティである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_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の余分なバイトが配列にあることを意味します。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ポインタが何であるかを説明するというより、Cポインタを使わないように警告しているように思えたからです。

さらにKagiで検索したところ、Redditでよりわかりやすい説明を見つけました。

[*c]Tは単なる型TへのCポインタで、そのポインタに複数の要素があるかどうかわからないことを示しています。あるかもしれないし、ないかもしれない。長さもわかりません(ポインタと長さを持つスライスではなく、単なるポインタです)。そして、複数の要素がある場合、それがヌル終端されているかどうかもわかりません。

-/u/slimsag on reddit

なるほど、こちらの方が理解しやすいです。

Cでは、ポインタは単なるメモリアドレスとデータ型です。char*というCの型は、'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は今度は1つ目ではなく3つ目のパラメータについて文句を言っています。少なくとも、最初の2つのパラメータには期待される型を渡せたことがわかります。

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

これは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ラッパーを作成する

ここまででCのus_base64_encode関数をZigから正常に呼び出せるようになりましたが、コードは少しごちゃごちゃしています。main()関数の大半が、Cとの間で値を変換する処理に費やされているからです。

コードを改善する1つの方法は、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で確保されたメモリバッファではなく、特別な解放関数(std.c.free)を必要とするCで確保されたバッファだからです。

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

出力サイズが21ではなく20になったのは、基になるデータ型が変わったためです。以前は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ライセンスの下で使用しています。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント