Chapter 13Testing And Leak Detection

测试与泄漏检测

概述

好的测试简短、精确,并且言之有物。Zig的std.testing通过小巧、可组合的断言(expectexpectEqualexpectError)和默认检测泄漏的内置测试分配器,使这变得容易。结合分配失败注入,你可以测试那些否则难以触发的错误路径,确保你的代码正确且确定性地释放资源;参见10testing.zig

本章展示了如何编写富有表现力的测试,如何解释测试运行器的泄漏诊断,以及如何使用std.testing.checkAllAllocationFailures来使代码对error.OutOfMemory具有防弹能力,而无需编写数百个定制测试;参见11heap.zig

学习目标

  • 使用test块和std.testing助手编写专注的单元测试。
  • 在测试中使用std.testing.allocatordefer来检测和修复内存泄漏;参见04
  • 使用std.testing.checkAllAllocationFailures系统地测试OOM(内存不足)行为;参见10

使用std.testing进行基础测试

Zig的测试运行器会在你传递给zig test的任何文件中发现test块。断言是返回错误的普通函数,因此它们自然地与try/catch组合。

std.testing模块结构

在深入研究特定断言之前,了解std.testing中可用的完整工具集是很有帮助的。该模块提供了三类功能:断言函数、测试分配器和实用程序。

graph TB subgraph "std.testing 模块" MAIN["std.testing<br/>(lib/std/testing.zig)"] subgraph "断言函数" EXPECT["expect()"] EXPECT_EQ["expectEqual()"] EXPECT_ERR["expectError()"] EXPECT_SLICES["expectEqualSlices()"] EXPECT_STR["expectEqualStrings()"] EXPECT_FMT["expectFmt()"] end subgraph "测试分配器" TEST_ALLOC["allocator<br/>(GeneralPurposeAllocator)"] FAIL_ALLOC["failing_allocator<br/>(FailingAllocator)"] end subgraph "实用程序" RAND_SEED["random_seed"] TMP_DIR["tmpDir()"] LOG_LEVEL["log_level"] end MAIN --> EXPECT MAIN --> EXPECT_EQ MAIN --> EXPECT_ERR MAIN --> EXPECT_SLICES MAIN --> EXPECT_STR MAIN --> EXPECT_FMT MAIN --> TEST_ALLOC MAIN --> FAIL_ALLOC MAIN --> RAND_SEED MAIN --> TMP_DIR MAIN --> LOG_LEVEL end

本章重点介绍核心断言(expectexpectEqualexpectError)和用于泄漏检测的测试分配器。其他断言函数如expectEqualSlicesexpectEqualStrings提供专门的比较,而像tmpDir()这样的实用程序则有助于测试文件系统代码;参见testing.zig

期望:布尔值、相等性和错误

这个例子涵盖了布尔断言、值相等性、字符串相等性,以及期望一个被测函数返回一个错误。

Zig
const std = @import("std");

/// Performs exact integer division, returning an error if the divisor is zero.
/// This function demonstrates error handling in a testable way.
/// 执行精确整数除法,如果除数为零则返回错误。
/// 此函数以可测试的方式演示错误处理。
fn divExact(a: i32, b: i32) !i32 {
    // Guard clause: check for division by zero before attempting division
    // 防护条款:在尝试除法之前检查除以零
    if (b == 0) return error.DivideByZero;
    // Safe to divide: use @divTrunc for truncating integer division
    // 可以安全除法:使用 @divTrunc 进行截断整数除法
    return @divTrunc(a, b);
}

test "boolean and equality expectations" {
    // Test basic boolean expression using expect
    // expect() returns an error if the condition is false
    // 使用 expect 测试基本布尔表达式
    // 如果条件为假,expect() 返回错误
    try std.testing.expect(2 + 2 == 4);

    // Test type-safe equality with expectEqual
    // Both arguments must be the same type; here we explicitly cast to u8
    // 使用 expectEqual 测试类型安全相等性
    // 两个参数必须是相同类型;这里我们显式转换为 u8
    try std.testing.expectEqual(@as(u8, 42), @as(u8, 42));
}

test "string equality (bytes)" {
    // Define expected string as a slice of const bytes
    // 将期望字符串定义为 const 字节切片
    const expected: []const u8 = "hello";

    // Create actual string via compile-time concatenation
    // The ++ operator concatenates string literals at compile time
    // 通过编译时连接创建实际字符串
    // ++ 运算符在编译时连接字符串字面量
    const actual: []const u8 = "he" ++ "llo";

    // Use expectEqualStrings for slice comparison
    // This compares the content of the slices, not just the pointer addresses
    // 使用 expectEqualStrings 进行切片比较
    // 这比较切片的内容,而不仅仅是指针地址
    try std.testing.expectEqualStrings(expected, actual);
}

test "expecting an error" {
    // Test that divExact returns the expected error when dividing by zero
    // expectError() succeeds if the function returns the specified error
    // 测试 divExact 在除以零时返回期望的错误
    // 如果函数返回指定的错误,expectError() 成功
    try std.testing.expectError(error.DivideByZero, divExact(1, 0));

    // Test successful division path
    // We use 'try' to unwrap the success value, then expectEqual to verify it
    // If divExact returns an error here, the test will fail
    // 测试成功的除法路径
    // 我们使用 'try' 来解包成功值,然后使用 expectEqual 验证它
    // 如果 divExact 在这里返回错误,测试将失败
    try std.testing.expectEqual(@as(i32, 3), try divExact(9, 3));
}
运行
Shell
$ zig test basic_tests.zig
输出
Shell
All 3 tests passed.

通过构造进行泄漏检测

测试分配器(std.testing.allocator)是一个配置为跟踪分配并在测试完成时报告泄漏的GeneralPurposeAllocator。这意味着如果你的测试忘记释放内存,它们就会失败;参见10

测试分配器如何工作

测试模块提供了两个分配器:用于带泄漏检测的通用测试的allocator,和用于模拟分配失败的failing_allocator。了解它们的架构有助于解释它们的不同行为。

graph TB subgraph "lib/std/testing.zig中的测试分配器" ALLOC_INST["allocator_instance<br/>GeneralPurposeAllocator"] ALLOC["allocator<br/>Allocator interface"] BASE_INST["base_allocator_instance<br/>FixedBufferAllocator"] FAIL_INST["failing_allocator_instance<br/>FailingAllocator"] FAIL["failing_allocator<br/>Allocator interface"] ALLOC_INST -->|"allocator()"| ALLOC BASE_INST -->|"provides base"| FAIL_INST FAIL_INST -->|"allocator()"| FAIL end subgraph "在测试中使用" TEST["test block"] ALLOC_CALL["std.testing.allocator.alloc()"] FAIL_CALL["std.testing.failing_allocator.alloc()"] TEST --> ALLOC_CALL TEST --> FAIL_CALL end ALLOC --> ALLOC_CALL FAIL --> FAIL_CALL

testing.allocator包装了一个配置有堆栈跟踪和泄漏检测的GeneralPurposeAllocator。而failing_allocator则使用一个FixedBufferAllocator作为其基础,然后用失败注入逻辑包装它。两者都公开了标准的Allocator接口,使它们在测试中可以作为生产分配器的直接替代品;参见testing.zig

泄漏是什么样子的

下面的测试故意忘记free。运行器报告一个泄漏的地址,一个指向分配调用点的堆栈跟踪,并以非零状态退出。

Zig
const std = @import("std");

// This test intentionally leaks to demonstrate the testing allocator's leak detection.
// Do NOT copy this pattern into real code; see leak_demo_fix.zig for the fix.
// 此测试故意泄漏以演示测试分配器的泄漏检测。
// 不要将此模式复制到真实代码中;有关修复方法,请参阅 leak_demo_fix.zig。

test "leak detection catches a missing free" {
    const allocator = std.testing.allocator;

    // Intentionally leak this allocation by not freeing it.
    // 通过不释放来故意泄漏此分配。
    const buf = try allocator.alloc(u8, 64);

    // Touch the memory so optimizers can't elide the allocation.
    // 接触内存以便优化器无法省略分配。
    for (buf) |*b| b.* = 0xAA;

    // No free on purpose:
    // 故意不释放:
    // allocator.free(buf);
}
运行
Shell
$ zig test leak_demo_fail.zig
输出
Shell
[gpa] (err): memory address 0x… leaked:
… leak_demo_fail.zig:1:36: … in test.leak detection catches a missing free (leak_demo_fail.zig)

All 1 tests passed.
1 errors were logged.
1 tests leaked memory.
error: the following test command failed with exit code 1:
…/test --seed=0x…

“All N tests passed.”这行只断言测试逻辑;泄漏报告仍然会导致整个运行失败。修复泄漏以使套件变绿。04

用defer修复泄漏

在成功分配后立即使用defer allocator.free(buf)来保证在所有路径上都进行释放。

Zig
const std = @import("std");

test "no leak when freeing properly" {
    // Use the testing allocator, which tracks allocations and detects leaks
    // 使用测试分配器,它跟踪分配并检测泄漏
    const allocator = std.testing.allocator;

    // Allocate a 64-byte buffer on the heap
    // 在堆上分配 64 字节缓冲区
    const buf = try allocator.alloc(u8, 64);
    // Schedule deallocation to happen at scope exit (ensures cleanup)
    // 计划在作用域退出时进行释放(确保清理)
    defer allocator.free(buf);

    // Fill the buffer with 0xAA pattern to demonstrate usage
    // 用 0xAA 模式填充缓冲区以演示用法
    for (buf) |*b| b.* = 0xAA;

    // When the test exits, defer runs allocator.free(buf)
    // The testing allocator verifies all allocations were freed
    // 当测试退出时,defer 运行 allocator.free(buf)
    // 测试分配器验证所有分配都被释放
}
运行
Shell
$ zig test leak_demo_fix.zig
输出
Shell
All 1 tests passed.

04, mem.zig

泄漏检测生命周期

泄漏检测在每个测试结束时自动发生。理解这个时间线有助于解释为什么defer必须在测试完成之前执行,以及为什么即使测试断言通过,泄漏报告也会出现。

graph TB TEST_START["测试开始"] ALLOC_MEM["分配内存<br/>const data = try testing.allocator.alloc(T, n);"] USE_MEM["使用内存"] FREE_MEM["释放内存<br/>defer testing.allocator.free(data);"] TEST_END["测试结束<br/>分配器检查泄漏"] TEST_START --> ALLOC_MEM ALLOC_MEM --> USE_MEM USE_MEM --> FREE_MEM FREE_MEM --> TEST_END LEAK_CHECK["如果泄漏:测试失败,并带有分配的堆栈跟踪"] TEST_END -.->|"内存未释放"| LEAK_CHECK

当一个测试结束时,GeneralPurposeAllocator会验证所有已分配的内存是否都已释放。如果仍有任何分配存在,它会打印出显示泄漏内存分配位置的堆栈跟踪(而不是应该被释放的位置)。这种自动检查无需手动跟踪即可消除整类错误。关键是在成功分配后立即放置defer allocator.free(…​),以便它在所有代码路径上执行,包括提前返回和错误传播;参见heap.zig

分配失败注入

分配内存的代码即使在分配失败时也必须是正确的。std.testing.checkAllAllocationFailures会在每个分配点用一个失败的分配器重新运行你的函数,验证你清理了部分初始化的状态并正确传播了error.OutOfMemory;参见10

系统地测试OOM安全

这个例子使用checkAllAllocationFailures和一个执行两次分配并用defer释放它们的小函数。这个助手在每个分配点模拟失败;只有在没有发生泄漏并且正确转发了error.OutOfMemory的情况下,测试才会通过。

Zig
const std = @import("std");

fn testImplGood(allocator: std.mem.Allocator, length: usize) !void {
    const a = try allocator.alloc(u8, length);
    defer allocator.free(a);
    const b = try allocator.alloc(u8, length);
    defer allocator.free(b);
}

// No "bad" implementation here; see leak_demo_fail.zig for a dedicated failing example.
// 此处没有"坏"实现;有关专门的失败示例,请参阅 leak_demo_fail.zig。

test "OOM injection: good implementation is leak-free" {
    const allocator = std.testing.allocator;
    try std.testing.checkAllAllocationFailures(allocator, testImplGood, .{32});
}

// Intentionally not included: a "bad" implementation under checkAllAllocationFailures
// will cause the test runner to fail due to leak logging, even if you expect the error.
// See leak_demo_fail.zig for a dedicated failing example.
// 故意不包含:在 checkAllAllocationFailures 下的"坏"实现
// 将导致测试运行器因泄漏日志而失败,即使你期望错误。
// 有关专门的失败示例,请参阅 leak_demo_fail.zig。
运行
Shell
$ zig test oom_injection.zig
输出
Shell
All 1 tests passed.

checkAllAllocationFailures下一个故意“坏”的实现将导致测试运行器记录泄漏的分配并使整个运行失败,即使你expectError(error.MemoryLeakDetected, …)。在教学或调试时,请将失败的示例隔离开来;参见10

注意与警告

  • 测试分配器仅在编译测试时可用。试图在非测试代码中使用它会引发编译错误。
  • 泄漏检测依赖于确定性的释放。优先在分配后直接使用defer;避免隐藏的控制流跳过释放;参见04
  • 对于需要大量分配的集成测试,用arena分配器包装以提高速度,但仍将最终的后备路由通过测试分配器以保留泄漏检查;参见10

练习

  • 编写一个函数,从输入字节构建一个std.ArrayList(u8),然后清除它。使用checkAllAllocationFailures来验证OOM安全性;参见11
  • 在第一次分配后引入一个故意的提前返回,并观察泄漏检测器捕捉到一个缺失的free;然后用defer修复它。
  • 为一个在无效输入上返回错误的函数添加expectError测试;包括错误和成功路径。

替代方案和边缘情况

  • 如果你需要运行一个故意演示泄漏的套件,请将这些文件与你的通过测试分开,以避免CI运行失败。或者,将它们置于构建标志之后,并且只在本地选择加入;参见20
  • 在测试之外,你可以在调试构建中启用std.heap.GeneralPurposeAllocator泄漏检测来捕捉手动运行中的泄漏,但生产构建应该禁用昂贵的检查以提高性能。
  • 分配失败注入在小型的、自包含的助手上最有效。对于更高级别的工作流,请在隔离状态下测试关键组件,以保持诱导的失败空间可管理;参见37

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.