深入理解 Rust 编译器中#[test]属性的三步宏展开机制
【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust
#[test]是 Rust 程序员最常用的内置属性之一,它让测试代码可以自然地与业务代码共处同一源码文件。但它的运行机制远非表面那么简单:私有模块中的测试函数如何被main函数调用?rustc --test究竟做了什么?本文基于 rustc 源码树中的官方开发者指南 test-implementation.md,结合本仓库内 rustc 编译器与libtest标准测试库的真实实现,逐层剖析#[test]作为一次「语法级宏展开」在编译器内部经历的**重导出(Re-Exporting)、测试主函数生成(Harness Generation)、测试对象生成(Test Object Generation)**三步,并给出用-Z unpretty检查展开结果的实操方法。读完本文,你将理解从#[test] fn my_test()到最终可执行测试二进制之间的全部编译链路。
从一段普通测试说起:#[test]的表面与本质
几乎所有 Rust 开发者都写过这样的代码:
#[test] fn my_test() { assert!(2+2 == 4); }当程序以rustc --test或cargo test编译时,编译器会产出一个可执行文件,负责运行这个以及 crate 中所有其他测试函数。这种测试方式最大的优势是有机性(organic):测试与代码共生,你甚至可以把测试放进私有模块:
mod my_priv_mod { fn my_priv_func() -> bool {} #[test] fn test_priv_func() { assert!(my_priv_func()); } }私有项因此可以被轻松测试,无需操心如何把它们暴露给外部测试装置——这是 Rust 测试易用性的关键。但从语义上看这相当反常:既然测试不可见,main函数凭什么能调用它们?rustc --test到底做了什么?
答案在编译器内部:#[test]是实现在rustc_ast中的一次语法变换,本质上是一个「高级宏」,它分三步重写整个 crate。源码层面的展开逻辑位于 compiler/rustc_builtin_macros/src/test.rs(单个测试对象的生成)与 compiler/rustc_builtin_macros/src/test_harness.rs(遍历 crate、收集测试并合成测试主函数)。下面逐一展开。
第一步:重导出(Re-Exporting)——用__test_reexports穿透私有边界
如前所述,测试可能存在于私有模块中,因此我们需要一种不破坏既有代码的方式,把它们暴露给主函数。为此,rustc_ast会创建名为__test_reexports的本地模块,递归地重导出测试。上述示例会被展开为:
mod my_priv_mod { fn my_priv_func() -> bool {} pub fn test_priv_func() { assert!(my_priv_func()); } pub mod __test_reexports { pub use super::test_priv_func; } }现在测试可以通过my_priv_mod::__test_reexports::test_priv_func访问。对于更深的模块结构,__test_reexports会重导出包含测试的模块本身,于是位于a::b::my_test的测试变成a::__test_reexports::b::__test_reexports::my_test。
这个过程看起来相当安全,但有一个尖锐的问题:如果用户代码里已经存在一个手写的__test_reexports模块会怎样?答案是:什么都不会发生,两者互不干扰。要解释这一点,需要理解 Rust 的抽象语法树(AST)如何表示标识符(Ident)。每个函数、变量、模块的名字并非以字符串存储,而是以不透明的 Symbol 形式保存——本质上就是每个标识符对应的一个 ID 数字。编译器维护一张独立的哈希表,在需要时(例如打印语法错误)恢复 Symbol 的人类可读名称。
当编译器生成__test_reexports模块时,它会为这个标识符生成一个全新的 Symbol。因此,编译器生成的__test_reexports即使与你手写的那个共享同一名字,也不共享同一个 Symbol,代码生成阶段自然不会发生名字冲突。这一技术正是 Rust 宏卫生(macro hygiene)的基石——名字是否冲突取决于 Symbol 是否相同,而非字符串是否相同。
源码佐证:在 compiler/rustc_builtin_macros/src/test_harness.rs 的TestHarnessGenerator::visit_item中,编译器遍历 crate 的每个Item,遇到ModKind::Loaded模块时就递归下降并用add_test_cases聚合该模块子树中的测试,同时通过expn_id为每个测试的Ident打上不透明(Transparency::Opaque)的展开标记,保证其在语法上下文中独一无二。
第二步:测试主函数生成(Harness Generation)——合成调用test_main_static的main
现在测试已能从 crate 根部访问,接下来要用它们做点什么。rustc_ast会生成一个类似这样的模块(确切地说,是注入一个main函数):
#[main] pub fn main() { extern crate test; test::test_main_static(&[&path::to::test1, /*...*/]); }这里的path::to::test1是类型为test::TestDescAndFn的常量。
这个看似简单的变换透露了大量关于测试实际运行方式的信息:测试被聚合进一个数组,然后交给名为test_main_static的测试运行器。关键在于,存在一个属于 Rust 核心的 crate 叫test(即libtest),它实现了测试的全部运行时;test的接口是不稳定的,因此与之交互的唯一稳定方式就是#[test]宏本身。
源码佐证:mk_main函数(compiler/rustc_builtin_macros/src/test_harness.rs)精确地构建了这个main:
- 生成的
main被标记#[rustc_main](对应原文档中的#[main])、#[coverage(off)]和#[doc(hidden)]; - 函数体会先插入
extern crate test;,再调用测试运行器; - 运行器路径默认是
test::test_main_static,但当 panic 策略是abort(即panic=abort)时,会改用test::test_main_static_abort(library/test/src/lib.rs#L206-L256)——后者通过为每个测试派生子进程的方式在panic=abort下隔离失败; - 两个 crate 级属性可以定制该展开:
#![reexport_test_harness_main = "some_name"]改变main函数的名字(且不受卫生机制影响),#![test_runner(path)]则完全替换test::test_main_static为自定义运行器路径(见 compiler/rustc_builtin_macros/src/test_harness.rs 与get_test_runner)。
mk_tests_slice(compiler/rustc_builtin_macros/src/test_harness.rs)会把收集到的测试构造成&[&test1, &test2, ...]形式的数组引用,并按测试名排序——这个排序是「承重」的(load-bearing):libtest的 harness 依赖二分查找按名字定位测试(见 library/test/src/lib.rs#L190-L196 中TestList::new(owned_tests, TestListOrder::Sorted)的注释)。
第三步:测试对象生成(Test Object Generation)——把配置编码进TestDescAndFn
写过 Rust 测试的人可能熟悉测试函数上那些可选属性。例如,若预期测试会引发 panic,可以给它标注#[should_panic]:
#[test] #[should_panic] fn foo() { panic!("intentional"); }这说明测试不仅仅是简单的函数,还携带配置信息。test把这份配置数据编码进一个名为TestDesc的结构体。对于 crate 中的每个测试函数,rustc_ast会解析其属性并生成一个TestDesc实例,再把它与测试函数组合成TestDescAndFn结构体——也就是test_main_static操作的对象。给定一个测试,生成的TestDescAndFn实例形如:
self::test::TestDescAndFn{ desc: self::test::TestDesc{ name: self::test::StaticTestName("foo"), ignore: false, should_panic: self::test::ShouldPanic::Yes, allow_fail: false, }, testfn: self::test::StaticTestFn(|| self::test::assert_test_result(::crate::__test_reexports::foo())), }(注:文档撰写时的allow_fail字段在新版TestDesc中已由compile_fail、no_run、test_type等更丰富的字段取代,见下方源码对照。)
构造完这些测试对象的数组后,它们便经由第二步生成的 harness 交给测试运行器。
源码对照:rustc_builtin_macros中真实的字段生成
对照 compiler/rustc_builtin_macros/src/test.rs 中的expand_test_or_bench,可以确认每个字段的来源:
TestDesc字段 | 生成逻辑 | 说明 |
|---|---|---|
name | test::StaticTestName(item_path) | 由模块路径 + 函数名拼接(item_path函数跳过根模块名,见 test.rs) |
ignore | should_ignore(&item) | 检测属性#[ignore](test.rs) |
ignore_message | should_ignore_message(&item) | 支持#[ignore = "message"]携带忽略原因(test.rs) |
source_file/start_line/start_col/end_line/end_col | get_location_info(cx, fn_) | 通过source_map().span_to_location_info从函数Ident的Span提取源文件与行列区间(test.rs),供测试名对齐与失败定位使用 |
should_panic | should_panic(cx, &item) | 解析#[should_panic]与#[should_panic(expected = "...")],分别生成ShouldPanic::No、ShouldPanic::Yes、ShouldPanic::YesWithMessage(test.rs) |
compile_fail/no_run | 固定false | 这两类标志主要用于文档测试(doctest)路径,#[test]宏展开中不启用 |
test_type | test_type(cx) | 根据 crate 根路径后缀判断:src目录下为UnitTest,tests目录下为IntegrationTest,否则为Unknown(test.rs);枚举定义见 library/test/src/types.rs#L17-L28 |
testfn | test::StaticTestFn或test::StaticBenchFn | 测试场景生成StaticTestFn(|| test::assert_test_result(fn()));#[bench]场景生成接收&mut Bencher的StaticBenchFn。注意两种闭包都被标注#[coverage(off)],避免在-Cinstrument-coverage构建中被插桩(test.rs) |
生成的对象是一个pub const(const $ident: test::TestDescAndFn),附带#[cfg(test)]、#[rustc_test_marker = "path"]与#[doc(hidden)]三个属性(test.rs)。其中#[rustc_test_marker]是后续 harness 阶段收集测试的「标记」:get_test_name通过读取该属性的值拿到测试的完整路径名(compiler/rustc_builtin_macros/src/test_harness.rs)。
libtest侧的类型定义
测试运行时的核心类型都在 library/test/src/types.rs 中定义:
TestDesc:单个测试的描述信息——名称、是否忽略、忽略消息、源文件与行列、should_panic策略、compile_fail、no_run与test_type(types.rs);TestDescAndFn:desc与testfn的组合体(types.rs);TestFn:六种可执行形态——StaticTestFn、StaticBenchFn、StaticBenchAsTestFn与各自的动态版本DynTestFn、DynBenchFn、DynBenchAsTestFn(types.rs),其中静态变体是#[test]展开直接产生的形态,动态变体用于运行时按名注册的测试;TestName:StaticTestName、DynTestName与用于输出对齐的AlignedTestName(types.rs);ShouldPanic:定义在 library/test/src/options.rs,取值No、Yes、YesWithMessage。
测试函数的合法性检查:#[test]的签名约束
#[test]展开并非无条件进行。在 compiler/rustc_builtin_macros/src/test.rs 的check_test_signature中,编译器会校验测试函数的签名,违反任一约束都会直接报错(保证后续展开不产生误导性错误):
- 测试函数不能是
unsafe fn; - 测试函数不能是 async/协程(coroutine);
- 测试函数不能带任何参数(报错
functions used as tests can not have any arguments); - 使用
#[should_panic]的测试返回值必须为(); - 测试函数不允许携带除生命周期以外的泛型参数。
此外,如果#[test]被放在非自由函数(如关联函数、宏调用等)上,not_testable_error会给出 "thetestattribute may only be used on a free function" 之类的诊断(test.rs)。#[bench]则要求函数恰好接收一个&mut Bencher参数(check_bench_signature,test.rs)。
另一处细节:展开逻辑首先检查cx.ecfg.should_test——即只有在--test模式下才生成测试对象;非测试构建中,标注#[test]的函数会被整体移除(return vec![],test.rs),这就是#[test]函数不会污染正式二进制的原因。类似地,#[test_case](供自定义测试作者标记测试项,作用于函数、常量与静态项)也仅在should_test为真时生效,且会把目标项改为pub并打上rustc_test_marker标记(test.rs)。
检查生成的代码:用-Z unpretty查看宏展开结果
在nightly版rustc上,有一个不稳定的标志unpretty可以打印宏展开后的模块源码:
$ rustc my_mod.rs -Z unpretty=hirunpretty是-Z不稳定选项族的一员,取值含义在 compiler/rustc_session/src/config.rs 中有系统化说明:normal打印展开前的原始 AST,expanded打印宏展开后的 AST,hir打印 HIR(高层中间表示),此外还有ast-tree、hir,typed、expanded,hygiene等组合变体。想看#[test]展开前后的对比,可以分别执行:
# 展开前的 AST $ rustc my_mod.rs -Z unpretty=normal # 宏展开后的 AST(能看到生成的 __test_reexports 与测试常量) $ rustc my_mod.rs -Z unpretty=expanded # 宏展开后的 HIR(能看到合成的 main 与对 test_main_static 的调用) $ rustc my_mod.rs -Z unpretty=hir需要提醒的是:-Z标志仅存在于 nightly 工具链,且必须配合--test语义理解输出——例如单独运行rustc my_mod.rs -Z unpretty=hir时若未进入测试配置,#[test]项会被移除、也看不到合成的main;实际检查测试展开时通常配合rustc --test my_mod.rs -Z unpretty=hir使用。此外,展开生成的main与__test_reexports模块以Symbol卫生机制命名,expanded,hygiene变体可以额外展示这些语法上下文标记,帮助观察「同名不同 Symbol」的防冲突设计。
全景回顾:从#[test]到测试二进制的完整链路
把三步展开串起来,rustc --test的完整工作流是:
- 收集:
TestHarnessGenerator遍历整个 crate 的 AST,凡带有#[rustc_test_marker]标记的项(由#[test]、#[bench]、#[test_case]展开产生)都被记入测试清单,每个测试的Ident被施加不透明的 hygiene 标记(compiler/rustc_builtin_macros/src/test_harness.rs); - 清理:
EntryPointCleaner移除用户原有的main等入口点,避免与即将合成的测试main冲突(test_harness.rs); - 重导出:为各模块合成
__test_reexports,把私有测试以全新的Symbol公开重导出,实现卫生的名字提升; - 合成测试对象:为每个测试生成一个
pub const形态的TestDescAndFn(含TestDesc配置与包装为StaticTestFn的闭包); - 合成主函数:
mk_main生成#[rustc_main] pub fn main(),内部extern crate test;后调用test::test_main_static(&[&test1, &test2, ...])(panic=abort时为test_main_static_abort),测试数组按名排序以满足libtest的二分查找; - 运行:
libtest的 test_main_static 解析命令行参数,经console::run_tests_console调度执行(默认多线程并行、可指定--test-threads等参数),测试失败时以退出码101(ERROR_EXIT_CODE)结束进程(library/test/src/lib.rs#L170-L181)。
由此,#[test]表面上「测试与代码共生、私有项可直接测试」的易用性,底层其实是 rustc 在 AST 层面完成的一次精巧的语法变换:以 Symbol 卫生机制解决可见性与命名冲突,以libtest承载全部运行时,而-Z unpretty则是观察这一切的透视镜。理解了这条链路,无论是排查#[should_panic]的怪异行为、自定义#![test_runner],还是阅读编译器自身的测试基础设施(本仓库 tests 目录下的 ui / run-make 测试均依赖该机制),都能更加游刃有余。
【免费下载链接】rustEmpowering everyone to build reliable and efficient software.项目地址: https://gitcode.com/GitHub_Trending/ru/rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考