如何编写可维护的编译器各阶段测试?
这问题太接地气了——我之前做编译器测试的时候也踩过不少「预期结果写死导致测试频繁失效」的坑,结合Rust和通用编译器测试经验,给你分享几个实用思路:
核心方向:从「精确匹配」转向「语义等价验证」
你现在的测试是直接对比输出和预期结果,这种方式最大的问题是绑定了实现细节——只要你调整了输出的格式、内部结构(比如AST加个字段、IR换个指令名),测试就会失效,但其实核心功能并没有问题。所以我们要把测试的关注点从「输出长什么样」转到「输出做了什么/表达了什么语义」。
1. 词法分析(lex)阶段:聚焦有效token的语义
词法分析的核心是把源码转换成有意义的token序列,那些无关的元数据(比如行号、列号、空格注释)不应该成为测试的阻碍:
- 忽略非关键元数据:在Rust里,不要直接
assert_eq!(tokens, expected_tokens)(因为expected_tokens里的span字段很难写死),而是逐个断言token的类型和字面量,比如:for (actual, expected) in tokens.iter().zip(expected_kinds_and_literals) { assert_eq!(actual.kind, expected.0); assert_eq!(actual.literal, expected.1); // 跳过span等无关字段 } - 鲁棒性测试:用带不同空格、换行、注释的输入测试,确保输出的有效token序列一致。比如输入
let x=42;和let x = 42 ; // comment,得到的token序列应该是一样的(除了span)。
2. 语法分析(parse)阶段:验证AST的语义结构
AST的精确序列化(比如Debug输出、JSON)很容易因为结构调整失效,我们要关注的是AST表达的语法结构:
- 用结构化断言:Rust里可以用
assert_matchescrate来精准匹配AST的核心结构,比如:
这样即使你给assert_matches!( ast_root, AstNode::Function { name: "add", params: [AstParam { name: "a" }, AstParam { name: "b" }], body: AstNode::BinaryOp { op: BinaryOp::Add, left: AstNode::Ident("a"), right: AstNode::Ident("b") } } );Function节点加了return_type字段,只要核心的函数名、参数、body不变,测试依然通过。 - 错误场景抓类别而非精确消息:比如测试语法错误时,断言错误的类型是「未闭合的括号」「未知标识符」,而不是精确的错误字符串(除非错误消息是产品级需求)。
3. 中间表示(IR)阶段:测试行为而非形式
IR的迭代通常比较频繁(比如新增优化、调整指令结构),所以测试要关注IR的执行行为:
- 模拟执行IR:写一个简单的IR解释器,执行生成的IR,检查输入输出的结果是否符合预期。比如IR是计算
1+2,不管它用了临时变量%t1还是直接运算,只要解释器输出3,测试就通过。 - 抽象无关细节:忽略临时变量名、寄存器编号这类实现细节,只验证指令的依赖关系和计算逻辑。比如对于赋值指令,只要确认值的来源和目标正确就行,不用管临时变量叫什么。
4. 代码生成(generation)阶段:验证执行结果
目标代码(汇编、机器码、字节码)最容易因为优化、目标架构细节变化失效,所以:
- 跑起来测:把生成的代码编译成可执行文件,运行它,检查返回值、标准输出、副作用(比如文件写入)是否符合预期。在Rust里可以用
std::process::Command来执行程序并捕获输出:let output = Command::new("./generated_program") .output() .expect("Failed to run program"); assert_eq!(output.status.code(), Some(0)); assert_eq!(String::from_utf8_lossy(&output.stdout), "3\n"); - 分层测试:如果生成的是汇编,可以用汇编模拟器执行验证;如果是字节码,可以用虚拟机执行,不用纠结汇编指令的顺序或格式。
通用测试优化技巧
除了分阶段调整,还有几个通用方法能让测试更稳定:
- 参数化测试:用
rstest之类的库,把多个测试案例(边界值、典型案例、错误案例)批量传入测试函数,这样即使某个案例因为格式变化失效,其他案例还能覆盖核心功能。 - 快照测试的正确打开方式:如果用
insta这类快照库,不要对整个输出做快照,只对语义关键部分做快照(比如AST的核心结构序列化)。更新快照前一定要确认是语义变化还是格式变化,避免误更。 - 契约式测试:定义每个阶段的输入输出契约(比如「输入合法源码,lex阶段必须输出无错误的token序列」),测试契约而非具体实现。比如测试lex阶段不会崩溃,输出的有效token数量和预期一致。
- 回归测试:每次修改后跑全量测试,把之前导致失效的案例加入测试集,避免重复踩坑。
总的来说,核心就是让测试关注“做对了什么”,而不是“做成了什么样”,这样你的编译器即使迭代优化,测试也能保持稳定,不用频繁修改预期结果。
内容的提问来源于stack exchange,提问作者Mistodon
相关产品推荐
相关产品推荐

