8.单元测试
在本章中,我将深入探讨如何在 Zig 中进行单元测试。我们将讨论 Zig 中的测试工作流程,以及来自编译器test
的命令zig
。
8.1test
区块介绍
在 Zig 中,单元测试写在test
声明中,或者,我更喜欢称之为test
块中。每个test
块都使用关键字 编写test
。您可以选择使用字符串文字来编写标签,该标签负责标识您正在此特定块内编写的特定单元测试组test
。
在下面的示例中,我们测试两个对象(a
和b
)的和是否等于 4。Zigexpect()
标准库中的函数接收逻辑测试作为输入。如果此逻辑测试的结果为true
,则测试通过。但如果结果为false
,则测试失败。
你可以在块内编写任何你想要的 Zig 代码test
。这些代码的一部分可能是设置测试环境所需的命令,或者只是初始化单元测试中需要用到的一些对象。
const std = @import("std");
const expect = std.testing.expect;
test "testing simple sum" {
const a: u8 = 2;
const b: u8 = 2;
try expect((a + b) == 4);
}
1/1 file81c21dbf264e.test.testing simple sum...OKA
All 1 tests passed.
您可以test
在同一个 Zig 模块上编写多个块。此外,您可以将test
块与源代码混合使用,不会出现任何问题或后果。如果您将test
块与常规源代码混合使用,则当您从编译器执行我们在1.2.4 节中介绍的build
、build-exe
或命令build-obj
时,这些块将被编译器自动忽略。build-lib``zig
test
换句话说,zig
编译器仅在您要求时才构建并执行单元测试。默认情况下,编译器始终忽略test
Zig 模块中编写的块。编译器通常仅检查这些块中是否存在语法错误test
。
如果您查看 Zig 标准库1中大多数文件的源代码,您会发现这些test
块是与库的正常源代码一起编写的。例如,您可以在array_list
模块2中看到这一点。因此,Zig 开发人员决定采用的标准是将他们的单元测试与他们正在测试的功能的源代码放在一起。
每个程序员对此可能有不同的看法。有些人可能更喜欢将单元测试与应用程序的实际源代码分开。如果是这种情况,您只需tests
在项目中创建一个单独的文件夹,然后开始编写仅包含单元测试的 Zig 模块(例如,就像在 Python 项目中通常使用的那样pytest
),一切都会正常工作。归根结底,这取决于您的偏好。
8.2如何运行测试
如果zig
编译器默认忽略任何test
块,那么如何编译和运行单元测试呢?答案是编译器test
的命令zig
。通过运行该zig test
命令,编译器将找到test
Zig 模块中每个块的实例,并且它将编译并运行您编写的单元测试。
zig test simple_sum.zig
1/1 simple_sum.test.testing simple sum... OK
All 1 tests passed.
8.3测试内存分配
Zig 的优势之一是它提供了一些很棒的工具,可以帮助我们程序员避免(同时也能检测)内存问题,例如内存泄漏和双重释放。defer
关键字在这方面尤其有用。
在开发源代码时,程序员有责任确保您的代码不会产生此类问题。但是,您也可以在 Zig 中使用一种特殊类型的分配器对象,它能够自动为您检测此类问题。这就是该std.testing.allocator
对象。此分配器对象提供了一些基本的内存安全检测功能,能够检测内存泄漏。
正如我们在3.1.5 节中所述,要在堆上分配内存,需要使用分配器对象。使用这些对象在堆上分配内存的函数应该接收一个分配器对象作为其输入之一。使用这些分配器对象分配的堆上所有内存,也必须使用同一个分配器对象进行释放。
因此,如果您想测试函数执行的内存分配,并确保这些分配没有问题,您可以简单地为这些函数编写单元测试,其中将std.testing.allocator
对象作为这些函数的输入。
请看下面的示例,我定义了一个明显会导致内存泄漏的函数。因为我们分配了内存,但同时却没有在任何时候释放这块分配的内存。因此,当函数返回时,我们会失去对buffer
包含这块分配内存的对象的引用,因此,我们无法再释放这块内存。
请注意,test
我在代码块中使用了 来执行此函数std.testing.allocator
。分配器对象能够深入我们的程序,并检测内存泄漏。结果,此分配器对象返回“内存泄漏”的错误消息,以及显示内存泄漏确切位置的堆栈跟踪。
const std = @import("std");
const Allocator = std.mem.Allocator;
fn some_memory_leak(allocator: Allocator) !void {
const buffer = try allocator.alloc(u32, 10);
_ = buffer;
// Return without freeing the
// allocated memory
}
test "memory leak" {
const allocator = std.testing.allocator;
try some_memory_leak(allocator);
}
Test [1/1] leak_memory.test.memory leak...
[gpa] (err): memory address 0x7c1fddf39000 leaked:
./ZigExamples/debugging/leak_memory.zig:4:39: 0x10395f2
const buffer = try allocator.alloc(u32, 10);
^
./ZigExamples/debugging/leak_memory.zig:12:25: 0x10398ea
try some_memory_leak(allocator);
... more stack trace
8.4测试错误
一种常见的单元测试风格是查找函数中的特定错误。换句话说,你编写一个单元测试,尝试断言特定函数调用是否返回任何错误,或返回特定类型的错误。
在 C++ 中,你通常会使用测试框架3中的函数REQUIRE_THROWS()
或CHECK_THROWS()
来编写这种风格的单元测试。对于 Python 项目,你可能会使用4中的函数。而在 Rust 中,你可能会结合使用。Catch2
raises()``pytest
assert_eq!()``Err()
但在 Zig 中,我们使用模块expectError()
中的函数std.testing
。使用此函数,您可以测试特定的函数调用是否返回您期望的确切错误类型。要使用此函数,首先编写try expectError()
。然后,在第一个参数中,提供您期望从函数调用中得到的错误类型。然后,在第二个参数中,编写您期望失败的函数调用。
下面的代码示例演示了 Zig 中此类单元测试。请注意,在函数内部,alloc_error()
我们为对象分配了 100 个字节的内存,或者说是一个包含 100 个元素的数组ibuffer
。但是,在test
块中,我们使用的FixedBufferAllocator()
分配器对象空间限制为 10 个字节,因为buffer
我们提供给分配器对象的对象只有 10 个字节的空间。
这就是为什么alloc_error()
函数在这种情况下会引发OutOfMemory
错误。因为该函数试图分配的空间超过了分配器对象允许的空间。所以,本质上,我们正在测试一种特定类型的错误,即OutOfMemory
。如果alloc_error()
函数返回任何其他类型的错误,那么该expectError()
函数将导致整个测试失败。
const std = @import("std");
const Allocator = std.mem.Allocator;
const expectError = std.testing.expectError;
fn alloc_error(allocator: Allocator) !void {
var ibuffer = try allocator.alloc(u8, 100);
defer allocator.free(ibuffer);
ibuffer[0] = 2;
}
test "testing error" {
var buffer: [10]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
try expectError(error.OutOfMemory, alloc_error(allocator));
}
1/1 file81c24fea00d1.test.testing error...OKAll 1
tests passed.
8.5测试简单相等性
在 Zig 中,有一些不同的方法可以测试相等性。您已经看到,我们可以expect()
与逻辑运算符一起使用==
来重现相等性测试。但我们还有一些其他的辅助函数您应该了解,尤其是expectEqual()
、expectEqualSlices()
和expectEqualStrings()
。
expectEqual()
顾名思义,该函数是一个经典的相等性测试函数。它接收两个对象作为输入。第一个对象是你期望第二个对象中的值。而第二个对象是你拥有的对象,或者说是你的应用程序生成的对象。因此,expectEqual()
你实际上是在测试这两个对象中存储的值是否相等。
您可以在下面的示例中看到,执行的测试expectEqual()
失败了。因为对象v1
和v2
包含不同的值。
const std = @import("std");
test "values are equal?" {
const v1 = 15;
const v2 = 18;
try std.testing.expectEqual(v1, v2);
}
1/1 ve.test.values are equal?...
expected 15, found 18
FAIL (TestExpectedEqual)
ve.zig:5:5: test.values are equal? (test)
try std.testing.expectEqual(v1, v2);
^
0 passed; 0 skipped; 1 failed.
虽然该expectEqual()
函数很有用,但它不适用于数组。要测试两个数组是否相等,应该使用expectEqualSlices()
函数。该函数有三个参数。首先,提供要比较的两个数组中包含的数据类型。第二个和第三个参数对应于要比较的数组对象。
在下面的示例中,我们使用此函数测试两个数组对象(array1
和array2
)是否相等。由于它们确实相等,因此单元测试顺利通过,没有任何错误。
const std = @import("std");
test "arrays are equal?" {
const array1 = [3]u32{1, 2, 3};
const array2 = [3]u32{1, 2, 3};
try std.testing.expectEqualSlices(
u32, &array1, &array2
);
}
1/1 file81c25513148.test.arrays are equal?...OKAll
l 1 tests passed.
最后,您可能还想使用expectEqualStrings()
函数。顾名思义,您可以使用此函数来测试两个字符串是否相等。只需将要比较的两个字符串对象作为函数的输入即可。
如果函数发现两个字符串之间存在任何差异,那么函数将引发错误,并且打印一条错误消息,显示所提供的两个字符串对象之间的确切差异,如下例所示:
const std = @import("std");
test "strings are equal?" {
const str1 = "hello, world!";
const str2 = "Hello, world!";
try std.testing.expectEqualStrings(
str1, str2
);
}
1/1 t.test.strings are equal?...
====== expected this output: =========
hello, world!␃
======== instead found this: =========
Hello, world!␃
======================================
First difference occurs on line 1:
expected:
hello, world!
^ ('\x68')
found:
Hello, world!
^ ('\x48')