引言
「没有测试的系统级代码迟早会在别人的机器上崩溃」。Zig 把测试内建到语言与构建系统里:一个 zig test 命令、一个 test 块,就能完成单元测试、基准测试与模糊测试,不需要引入任何第三方框架。相比 C 项目「测试框架 + CMake 脚本 + 覆盖率工具」的多套拼装,Zig 测试是语言的一部分,编译期就知道哪些测试存在。
本文从 test 块与断言讲起,覆盖 std.testing 断言家族、testing.allocator 内存泄漏检测(系统编程测试的核心价值)、测试的组织与跨文件引用,再到基准测试与 libFuzzer 模糊测试,最后落到 build.zig 与 CI 集成。
前置:/zig-language-basics/(语法基础)、/zig-memory-management/(Allocator 体系,理解 testing.allocator 的前提)。
目录
- 1. 测试基础:test 块与 zig test
- 2. std.testing 断言家族
- 3. testing.allocator 内存泄漏检测
- 4. 测试的组织与跨文件引用
- 5. 基准测试方法论
- 6. 模糊测试:libFuzzer 与结构化输入
- 7. build.zig 集成与 CI
- 8. 测试驱动开发在 Zig 中的实践
- 9. 速查表
- 10. 一句话记忆
- 延伸阅读
1. 测试基础:test 块与 zig test
在 Zig 中,测试就是写在 test 块里的代码。test 块不是普通函数,它由测试运行器在编译后执行:
const std = @import("std");
fn add(a: i32, b: i32) i32 {
return a + b;
}
test "add works" {
try std.testing.expectEqual(@as(i32, 4), add(2, 2));
}
zig test src/foo.zig
# 输出:All 1 tests passed.
关键特性:
| 特性 | 说明 |
|---|---|
test "描述" { ... } | 测试块,块名是可读描述 |
zig test file.zig | 编译并运行所有 test 块 |
--test-filter 子串 | 只运行名字包含子串的测试 |
匿名 test { ... } | 允许命名同名测试(用块首表达式区分) |
测试内可 return error.SkipZigTest | 跳过当前测试 |
注意:
zig test的入口不是main,而是测试运行器。生产代码的main不会被调用,除非你显式引用它。
2. std.testing 断言家族
std.testing 提供了一组类型安全的断言,失败时输出带源位置的详细诊断:
const std = @import("std");
const testing = std.testing;
test "assertion family" {
try testing.expect(true);
try testing.expectEqual(@as(u8, 42), 42); // 等值,输出两侧值
try testing.expectNotEqual(@as(u8, 1), 2);
try testing.expectEqualStrings("hi", "hi"); // 字符串比较
try testing.expectApproxEqRel(@as(f64, 1.0), 1.001, 0.01); // 浮点相对误差
try testing.expectError(error.OutOfMemory, mayFail());
try testing.expectEqualSlices(u8, &[_]u8{1, 2}, &[_]u8{1, 2});
}
常用断言对照:
| 断言 | 用途 |
|---|---|
expect(bool) | 通用条件 |
expectEqual(a, b) | 任意类型的等值(含可选值与错误联合) |
expectEqualStrings | 字节串比较 |
expectApproxEqRel / expectApproxEqAbs | 浮点近似 |
expectError(err, expr) | 期望特定错误 |
expectEqualSlices(T, a, b) | 切片逐元素比较 |
expectEqualDeep | 递归比较复杂结构 |
断言失败时会打印两边的实际值与调用栈,这是 C 里 assert 完全做不到的调试体验。
3. testing.allocator 内存泄漏检测
系统编程测试与业务测试最大的区别:必须验证内存行为。Zig 的 testing.allocator 会在每次 alloc/free 时记账,测试结束时若存在未释放或双重释放,直接报错:
const std = @import("std");
const testing = std.testing;
fn buildGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
const greeting = try std.fmt.allocPrint(allocator, "Hello, {s}!", .{name});
return greeting;
}
test "no leak" {
var list = std.ArrayList(u8).init(testing.allocator);
defer list.deinit(); // 释放所有元素
try list.append('x');
const s = try buildGreeting(testing.allocator, "Zig");
defer testing.allocator.free(s);
try testing.expectEqualStrings("Hello, Zig!", s);
}
testing.allocator 能捕获:
□ 泄漏(alloc 后从未 free)→ "Test leaked N bytes..."
□ 双重释放(free 两次)→ "Double free detected"
□ 越界写(buffer overflow,在分配区尾部布置防护字节)
□ use-after-free(释放后访问)
心法:生产代码永远把 Allocator 作为参数传入,测试时注入 testing.allocator,就能免费获得内存正确性验证。这是 Zig 测试超越大多数语言的关键设计。
4. 测试的组织与跨文件引用
同一文件内测试:直接把 test 块写在函数/类型附近,随模块一起编译。
跨文件测试:通过 @import 引用目标模块,但要注意测试入口编译的是「引用测试的文件」,不会自动带上被引用文件的测试。要收集所有测试,用 zig build test 或在根文件中引用:
// src/all_tests.zig
test {
// 引用测试文件 → 把它们的 test 块收集进本文件的编译单元
_ = @import("arraylist_test.zig");
_ = @import("hashmap_test.zig");
_ = @import("format_test.zig");
}
命名规范:
test "arraylist: append grows capacity" { ... }
test "hashmap: overwrite keeps insertion order" { ... }
- 用
模块: 行为的前缀描述,方便--test-filter按模块过滤。 - 一个测试只验证一件事;失败信息要能定位到具体断言。
5. 基准测试方法论
Zig 没有单独的基准测试框架,而是用标准库计时 + 编译器防优化实现:
const std = @import("std");
const testing = std.testing;
fn hashData(seed: u64, data: []const u8) u64 {
var h = seed;
for (data) |b| h = h *% 31 +% b;
return h;
}
test "bench: hashData 1M bytes" {
var prng = std.Random.DefaultPrng.init(42);
const data = try testing.allocator.alloc(u8, 1_000_000);
defer testing.allocator.free(data);
prng.random().bytes(data);
var timer = std.time.Timer.start() catch unreachable;
var result: u64 = 0;
for (0..100) |_| result ^= hashData(result, data); // 多次跑,摊平噪声
const elapsed = timer.read();
std.debug.print("hashData: {d} ns/iter ({d} MB/s)\n", .{
elapsed / 100, 1_000_000 * 100 / (elapsed / 1000),
});
try testing.expect(result != 0); // 防止编译器把循环整体优化掉
}
基准测试纪律:
| 做法 | 说明 |
|---|---|
| 多次迭代取平均 | 单次受调度/缓存噪声影响大 |
用 ^ 累加结果 | 阻止 dead-code elimination |
| 预热缓存 | 先跑几轮再计时 |
| 对比相对值 | 报告 ns/iter 与吞吐,而非只给总耗时 |
| 固定频率与任务 | 在专用核上跑(taskset)可进一步降噪 |
Zig 的
std.time.Timer基于操作系统单调时钟;跨平台代码建议用std.time.nanoTimestamp()做粗粒度计时。
6. 模糊测试:libFuzzer 与结构化输入
模糊测试(fuzzing)自动生成输入寻找崩溃,是解析器、网络协议、反序列化代码的必备手段。Zig 支持直接对接 libFuzzer:
// src/fuzz.zig —— 目标函数接收不可信输入
const std = @import("std");
pub fn parseLine(line: []const u8) !u64 {
// 假设这是你的解析器:恶意输入不应崩溃、不应 OOB
return std.fmt.parseInt(u64, line, 10);
}
// src/fuzz_target.zig —— libFuzzer 入口
const fuzz = @import("fuzz.zig");
const std = @import("std");
export fn LLVMFuzzerTestOneInput(data: [*]const u8, size: usize) callconv(.C) void {
const slice = data[0..size];
fuzz.parseLine(slice) catch return; // 返回而非崩溃
}
zig build-exe src/fuzz_target.zig -O ReleaseSafe -fsanitize-c=undefined \
-femit-bin=fuzz -lc
# 用 clang 的 libFuzzer 驱动
clang -fsanitize=fuzzer -o fuzz_driver fuzz_target.o /path/to/libFuzzer
./fuzz_driver -max_len=64 -artifact_prefix=./crashes/
模糊测试要点:
- 目标函数必须「失败返回」而非 panic 或越界——让 fuzzer 的崩溃报告直接指向 bug。
- 用
-O ReleaseSafe保持安全检查(越界/溢出检测仍开启)。 - 配合
testing.allocator风格防护,可以捕捉 use-after-free 类内存 bug。 - 保存每次崩溃的最小复现输入(artifact),回归到单元测试中。
7. build.zig 集成与 CI
把测试挂进构建系统,zig build test 一键运行,并能在 CI 上捕获失败:
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const unit_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
}),
});
const run_tests = b.addRunArtifact(unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_tests.step);
}
CI 最佳实践:
□ zig build test → 单元测试
□ zig build test -Doptimize=ReleaseSafe → 优化下的测试(暴露 UB)
□ zig build → 确认可编译
□ zig fmt --check src/ → 格式一致性
□ 条件跑模糊测试 → 仅 PR 或定时任务,避免 CI 超时
提示:
-Doptimize=ReleaseSafe跑一遍测试非常值得——很多只在优化下才暴露的未定义行为,Debug 模式测不出来。
8. 测试驱动开发在 Zig 中的实践
TDD 在系统编程同样适用,关键是把「可测性」设计进接口:
1. 依赖注入 Allocator —— 函数不硬编码 page_allocator,而是收 std.mem.Allocator 参数:
fn parsePacket(allocator: std.mem.Allocator, bytes: []const u8) !Packet
2. 纯函数优先 —— 解析、编码、校验做成不碰 IO 的纯函数,IO 留薄壳,测试覆盖纯逻辑。
3. 用错误联合表达失败路径 —— 每个错误分支都是一条可断言的测试路径:
test "parse rejects short header" {
try testing.expectError(error.ShortHeader, parsePacket(testing.allocator, &.{1, 2}));
}
4. 属性测试思想 —— 手写几组边界输入(空输入、最大长度、畸形长度字段),比堆 50 个相似用例更有效。
9. 速查表
| 需求 | 手段 |
|---|---|
| 单元测试 | test "..." { ... } + zig test |
| 断言 | std.testing.expectEqual/expectEqualStrings/expectError |
| 内存正确性 | testing.allocator 注入(泄漏/双释/OOB) |
| 跨文件测试收集 | 根文件 _ = @import("xxx_test.zig") |
| 过滤单测 | zig test --test-filter 关键字 |
| 基准 | std.time.Timer + 结果累加防优化 |
| 模糊测试 | libFuzzer 入口 LLVMFuzzerTestOneInput |
| 构建集成 | b.addTest + b.step("test", ...) |
| CI | zig build test + -Doptimize=ReleaseSafe + zig fmt --check |
10. 一句话记忆
Zig 测试内建在语言里:test 块写断言、testing.allocator 查内存、zig test 一键跑、libFuzzer 抓崩溃——系统级代码的测试从不该是事后补丁,而是接口设计的一部分(Allocator 依赖注入让一切可测)。
延伸阅读
- /zig-language-basics/ — test 块在语言层面的基础语法
- /zig-memory-management/ — Allocator 体系与 testing.allocator 的机制
- /zig-build-system/ — addTest 步骤与构建系统集成
- /zig-error-handling/ — 错误联合让测试可断言每个失败路径
- [[zig]] — Zig 系统编程专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。