测试
在 1972 年的文章《谦卑的程序员》中,Edsger W. Dijkstra 写道:"程序测试可以是 展示 bug 存在的非常有效的方法,但对于展示 bug 不存在来说,则是完全不够的。" 这并不意味着我们不应该尽可能地多写测试。
我们程序中的_正确性_是指代码做我们期望它做的事情的程度。Auto 在设计时高度 关注正确性——类型系统、所有权规则和借用检查器在编译时就能捕获许多 bug。但类型 系统无法捕获所有问题。这就是自动化测试发挥作用的地方。
Auto 的测试系统与 Rust 的测试系统紧密对齐,因为 Auto 会转译为 Rust 并使用 相同的测试运行器来运行测试。本章介绍 Auto 中可用的测试机制。
为什么测试很重要
_测试_是验证非测试代码是否按预期运行的函数。测试函数通常执行三个操作:
- 设置所需的数据或状态。
- 运行你要测试的代码。
- 断言结果符合你的预期。
编写测试有具体的好处。测试能_及早发现 bug_——当一个测试失败时,你确切知道 哪个行为出了问题。测试让你_自信地重构_——如果更改后所有测试都通过,你很可能 没有破坏任何东西。测试充当_文档_——阅读测试告诉你一个函数应该做什么。
在 Auto 中,测试特别有价值,因为它们在转译后的 Rust 代码上运行。如果你的 Auto 代码能编译通过且所有测试都通过,你就有了强有力的证据证明代码在所有五个 目标平台上都是正确的:Rust、Python、C、TypeScript 和 Auto 本身。
你的第一个测试
Auto 中的测试是用 #[test] 标注的函数。当你运行 automan test 时,转译器 将你的代码编译为 Rust 并运行 cargo test,后者构建一个测试运行器二进制文件 来执行所有标注的函数。
#[test] 属性将函数标记为测试。测试运行器找到所有带有此属性的函数并将它们 作为独立的测试用例运行。如果测试函数没有 panic 就返回了,测试通过。如果发生 了 panic(或断言失败),测试失败。
assert_eq 宏检查两个值是否相等。如果不相等,测试会 panic 并显示一条消息, 展示期望值和实际值。这使得调试比简单的 assert 容易得多——你可以确切看到什么 出了问题。
在 Rust 中,assert_eq! 使用 ! 宏后缀并且需要分号。在 Auto 中,assert_eq 不使用 ! 也不需要分号。转译器在生成 Rust 代码时会自动添加。
在 Python 中,assert_eq 转译为普通的 assert result == 4 语句。在 C 中, 它变成带有 fprintf 和 __builtin_trap 的 if 检查。在 TypeScript 中, 它变成抛出 Error 的 if 检查。每个目标平台都使用其惯用的断言机制。
断言宏
Auto 提供三种断言宏,覆盖最常见的测试需求:
assert_eq(a, b)—— 如果a等于b则通过。失败时显示两个值。assert_ne(a, b)—— 如果a不等于b则通过。失败时显示两个值。assert!(condition)—— 如果condition为true则通过。失败时不 显示额外上下文。
这个测试演示了全部三种宏:
assert_eq(s.len(), 5)—— 验证字符串长度恰好为 5。如果字符串是 "hi"(长度 2),测试将失败并显示两个值:assertion failed: left == right, left: 2, right: 5。assert_ne(s, "world")—— 验证字符串不是 "world"。当你想确认某个值 不同于特定的哨兵值或错误值时,这很有用。assert!(s.contains("ell"))—— 验证一个布尔条件为true。这是最通 用的断言——你可以将它用于任何返回布尔值的表达式。如果条件为false, 测试会以一条通用消息失败(不显示值,因为assert!不知道该显示哪些值)。
一个好的做法是尽可能使用 assert_eq 和 assert_ne,因为它们在失败时会显示两 个值。将 assert! 留给值从上下文中显而易见的情况,或者用于测试返回布尔值的 方法(如 .contains())。
在 Python 中,s.contains("ell") 转译为惯用的 in 运算符:"ell" in s。 在 C 中,字符串操作使用 <string.h> 中的 strlen、strcmp 和 strstr。 在 TypeScript 中,.contains() 映射为 .includes()。
测试错误情况
许多函数返回 !T(Result)来表示成功或失败。测试这些函数意味着需要同时测试 成功路径和错误路径。Auto 通过将 !T 与 is 模式匹配结合使用,使这变得很自然。
让我们分解关键思想:
fn divide(a float, b float) !float——!float返回类型表示此函数 返回Result<float, String>。成功时返回商。失败时(除以零)返回错误 字符串。if b == 0.0 { "division by zero" } else { a / b }—— 在 Auto 中, 错误返回函数中的裸字符串隐式变为Err("division by zero")。裸值变为Ok(a / b)。这是 Auto 简洁的错误处理语法。转译器在 Rust 中将这些展开 为显式的Ok(...)和Err(...)构造器。test_divide_by_zero—— 此测试调用divide(10.0, 0.0)并对结果进行 模式匹配。如果得到Ok(_),测试以assert!(false)失败——我们期望的 是错误,不是成功。如果得到Err(e),测试通过。Ok(_)中的_表示 "我不关心这个值"(因为错误情况下不应该有值)。test_divide_success—— 此测试调用divide(10.0, 2.0)并验证结果。 如果得到Ok(v),我们使用assert_eq检查v == 5.0。如果得到Err(_),测试以assert!(false)失败——除法本应该成功。
测试 !T 结果的两个分支的模式是基础性的。每个可能失败的函数都应至少有两个 测试:一个用于成功路径,一个用于每个错误路径。
在 Python 中,!T 类型转译为带有 ok 标志的自定义 Result 类。在 C 中, 它变成一个带有布尔判别值的结构体以及 ok 值和错误值的联合。在 TypeScript 中, 它变成带判别值的联合类型,形状为 { ok: true; value: T } 和 { ok: false; error: string }。
集成测试
单元测试在隔离环境中验证单个函数。_集成测试_验证多个组件是否正确协同工作。 它们测试完整的工作流——创建对象、调用多个方法、检查最终状态。
关键观察:
type Calculator { total float }—— 一个带有一个字段的简单类型。total字段跟踪累加和。fn new() Calculator—— 一个静态构造函数,创建一个total设为0.0的Calculator。在 Auto 中,ext块内的fn new()是静态方法(没有self参数)。fn add(&self, value float)—— 一个修改self.total的方法。&self参数告诉编译器此方法可能修改接收者。在 Rust 中,这转译为&mut self。 在 Python 和 TypeScript 中,self(或this)是隐式的。fn clear(&self)—— 将total重置为0.0。这是另一个修改方法。测试 —— 创建一个计算器,添加两个值,检查总和,清除它,然后检查总和 是否回到零。这是一个多步工作流测试。它验证
add和clear单独使用 以及组合使用时都能正确工作。
单元测试和集成测试之间的关键区别在于范围。add 的单元测试可能只测试一次 加法:calc.add(5.0); assert_eq(calc.total, 5.0)。集成测试则练习完整的工作 流:创建、多次添加、检查、清除、再次检查。两者都有价值。
在 C 中,&self 方法变成接受指向结构体的指针(Calculator* self)的函数。 调用者必须显式传递 &calc。在 TypeScript 中,Calculator.new() 是一个返回 新实例的静态工厂方法。
测试组织
随着项目增长,你需要一种组织测试的策略。Auto 遵循与 Rust 相同的约定:单元 测试与被测代码放在一起,集成测试放在单独的 tests/ 目录中。
你还可以使用 spec(来自第 13 章)定义你要测试的接口,并针对该 接口编写测试。这让你可以灵活地替换实现而无需更改测试。
以下是 Auto 中测试组织的工作方式:
spec MathUtils—— 定义契约:任何MathUtils的实现都必须提供add函数。测试可以针对此规格编写,因此适用于任何实现。ext MathUtils—— 提供默认实现。add函数简单地返回a + b。如果 以后需要一个记录调用或处理溢出的版本,你可以替换实现而无需更改测试。#[test] fn test_add()—— 测试函数调用MathUtils.add并验证结果。 因为测试使用规格名称,所以它适用于任何符合规格的实现。fn main()—— 测试运行器会忽略main函数。只有标记了#[test]的 函数在运行automan test时才会被执行。
运行测试
运行所有测试:
$ automan test按名称运行单个测试:
$ automan test test_add运行名称匹配模式的测试:
$ automan test divide显示通过测试的输出:
$ automan test -- --show-output一次运行一个测试(用于调试):
$ automan test -- --test-threads=1测试语法快速参考
Auto Rust 用途 #[test]#[test]将函数标记为测试 assert_eq(a, b)assert_eq!(a, b)断言相等 assert_ne(a, b)assert_ne!(a, b)断言不等 assert!(cond)assert!(cond)断言条件为真 fn test() !fn test() -> Result<(), String>返回错误而非 panic
总结
本章介绍了 Auto 的测试系统。以下是我们学到的内容:
| 概念 | 语法 | 用途 |
|---|---|---|
| 测试属性 | #[test] | 将函数标记为测试 |
| 相等断言 | assert_eq(a, b) | 验证两个值相等 |
| 不等断言 | assert_ne(a, b) | 验证两个值不同 |
| 布尔断言 | assert!(cond) | 验证条件为真 |
| 错误测试 | is result { Ok => ..., Err => ... } | 测试 !T 的两个路径 |
| 运行测试 | automan test | 执行所有测试函数 |
核心要点:
测试是带
#[test]的函数 —— 测试运行器发现并执行它们。如果函数没有 panic 就返回了,测试通过。优先使用
assert_eq和assert_ne—— 它们在失败时显示两个值,使调试 更容易。只有当值不言自明时才使用assert!。测试
!T的两个路径 —— 每个可能失败的函数都应至少有一个成功路径的 测试和每个错误路径的测试。集成测试练习工作流 —— 测试多个组件协同工作:创建、操作、验证、重置、 再次验证。
spec启用可测试接口 —— 针对规格编写测试,使其适用于任何符合的实现。automan test运行一切 —— 它转译为 Rust 并调用cargo test,给你 Rust 测试运行器的全部功能。
测试不仅仅是为了发现 bug。它是为了对你的代码正确运行有信心——今天如此,未来 每次更改后也是如此。编写测试的小投资会通过减少调试时间和增加重构信心而得到 许多倍的回报。