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
    return @divTrunc(a, b);
}

test "boolean and equality expectations" {
    // Test basic boolean expression using expect
    // expect() returns an error if the condition is false
    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
    try std.testing.expectEqual(@as(u8, 42), @as(u8, 42));
}

test "string equality (bytes)" {
    // Define expected string as a slice of const bytes
    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
    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
    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 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 接口"] BASE_INST["base_allocator_instance<br/>FixedBufferAllocator"] FAIL_INST["failing_allocator_instance<br/>FailingAllocator"] FAIL["failing_allocator<br/>Allocator 接口"] ALLOC_INST -->|"allocator()"| ALLOC BASE_INST -->|"提供基础"| FAIL_INST FAIL_INST -->|"allocator()"| FAIL end subgraph "测试中的使用" TEST["test 块"] 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 包装了一个配置有堆栈跟踪和泄漏检测的 GeneralPurposeAllocatorfailing_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.

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" {
    // 使用测试分配器,它跟踪分配并检测泄漏
    const allocator = std.testing.allocator;

    // 在堆上分配64字节缓冲区
    const buf = try allocator.alloc(u8, 64);
    // 安排在作用域退出时释放(确保清理)
    defer allocator.free(buf);

    // 用0xAA模式填充缓冲区以演示用法
    for (buf) |*b| b.* = 0xAA;

    // 当测试退出时,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["如果泄漏:测试失败<br/>分配堆栈跟踪"] 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.

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.
运行
Shell
$ zig test oom_injection.zig
输出
Shell
All 1 tests passed.

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

注意事项

  • 测试分配器仅在编译测试时可用。尝试在非测试代码中使用它会触发编译错误。
  • 泄漏检测依赖于确定性释放。优先在分配后立即使用 defer;避免跳过释放的隐藏控制流;参见 04
  • 对于需要大量分配的集成测试,使用竞技场分配器包装以提高速度,但仍通过测试分配器路由最终支持以保持泄漏检查;参见 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.