When a Compile-Fail Test Fails for the Wrong Reason
When a Compile-Fail Test Fails for the Wrong Reason
当编译失败测试因错误的原因而失败时
I found a test recently that was doing exactly what it was supposed to do: it failed to compile. The problem was that it failed before it reached the thing I thought I was testing. That was a useful reminder. With compile-fail tests, “the compiler rejected it” is not enough. The reason for the rejection matters. 最近我发现一个测试确实完成了它该做的事:它编译失败了。但问题在于,它在触及我预想的测试点之前就失败了。这给了我一个有益的提醒:对于“编译失败(compile-fail)”测试而言,仅仅“编译器拒绝了它”是不够的,拒绝的原因至关重要。
I had a few cases like this while hardening some small Rust types. The production code was mostly fine. The stale part was the verification around it. The examples below are simplified, and the type names are intentionally generic. 在加固一些小型 Rust 类型时,我遇到了几个这样的案例。生产代码本身基本没问题,过时的是围绕它的验证逻辑。下面的示例经过了简化,类型名称也是故意设为通用的。
The compiler never reached the privacy boundary
编译器从未触及隐私边界
Imagine a public type with a private field and const-generic bounds: 想象一个带有私有字段和常量泛型约束的公共类型:
pub struct BoundedValue<const MIN: u64, const MAX: u64> {
value: u64,
}
Code outside the defining module should not be able to construct it by writing the field directly. A compile-fail test for that boundary might start like this: 定义模块之外的代码不应通过直接写入字段来构造它。针对该边界的编译失败测试可能如下所示:
let _ = BoundedValue { value: 5, };
It certainly does not compile. But in the case I was reviewing, the omitted const parameters caused a type-inference error first. The compiler had rejected the program without actually proving that direct field construction was blocked. The test looked healthy because it failed. The diagnostic told a different story. 它确实无法编译。但在我审查的案例中,省略的常量参数首先导致了类型推断错误。编译器在还没来得及验证直接字段构造是否被阻止之前,就拒绝了该程序。测试因为失败了,看起来很“健康”,但诊断信息却说明了另一回事。
Making the const parameters explicit removed the unrelated ambiguity: 显式指定常量参数消除了这种不相关的歧义:
let _ = BoundedValue::<0, 10> { value: 5, };
Now the test can actually reach the privacy boundary. That is a much better negative test. Not because it fails “more strongly,” but because it fails for the property I meant to check. 现在测试终于可以触及隐私边界了。这是一个更好的负面测试(negative test)。不是因为它“失败得更彻底”,而是因为它确实是因为我想要检查的那个属性而失败的。
Stale syntax can hide the same problem
过时的语法会掩盖同样的问题
I ran into another version of this with a named-field byte wrapper. Simplified: 我在一个带命名字段的字节包装器中遇到了类似的情况。简化如下:
pub struct ByteBlock<const N: usize> {
bytes: [u8; N],
}
An old compile-fail case was trying to construct it using tuple-struct syntax: 一个旧的编译失败测试用例试图使用元组结构体语法来构造它:
let _ = ByteBlock([0u8; 32]);
Again, the compiler rejected it. But that test was not checking field privacy. It was only demonstrating that ByteBlock was not a tuple struct. The type had changed shape at some point, while the negative test had kept an older idea of the API. If privacy is the thing I want to verify, the misuse needs to match the real shape of the type:
编译器再次拒绝了它。但该测试并没有检查字段隐私,它只是证明了 ByteBlock 不是一个元组结构体。该类型的结构在某个时间点发生了变化,而负面测试却保留了对 API 的旧有认知。如果我想要验证的是隐私性,那么这种误用必须匹配该类型的真实结构:
let _ = ByteBlock::<32> { bytes: [0u8; 32], };
From outside the defining module, that gives the compiler a chance to reject the actual private-field access. The old test contained a real compiler error. It was just evidence for the wrong claim. 从定义模块外部来看,这给了编译器拒绝实际私有字段访问的机会。旧测试确实包含一个编译错误,但它只是证明了错误的结论。
This is easy to miss with trybuild
使用 trybuild 时很容易忽略这一点
I like trybuild for this kind of testing. A compile-fail case is compiled separately, and the compiler output is compared with the expected .stderr file. That makes regressions in diagnostics visible, which is useful. It also means I need to be careful when accepting or updating those snapshots. If a stale test starts failing for an incidental reason and I simply bless the new .stderr, I can preserve a broken test while making the suite green again. That is not a problem with trybuild. It is a problem with what I am asking the test to establish.
我喜欢用 trybuild 进行此类测试。编译失败用例会被单独编译,并将编译器输出与预期的 .stderr 文件进行比较。这使得诊断信息的回归变得可见,非常有用。这也意味着我在接受或更新这些快照时必须小心。如果一个过时的测试因为偶然原因开始失败,而我只是简单地更新了新的 .stderr,我可能会在让测试套件变绿的同时,保留了一个失效的测试。这不是 trybuild 的问题,而是我要求测试去验证的内容本身出了问题。
I used to look at a successful compile-fail suite and move on fairly quickly. Now I read the diagnostic and ask whether it actually reaches the boundary named by the test. 过去,我看到编译失败测试套件通过后很快就会跳过。现在,我会阅读诊断信息,并询问它是否真的触及了测试所命名的边界。
A compile-fail test should make one clear claim
编译失败测试应做出明确的断言
The rule I use now is simple: I want each negative test to have one intended reason to fail. Before I accept one, I check: 我现在使用的规则很简单:我希望每个负面测试都有一个明确的失败原因。在接受测试之前,我会检查:
- Claim: What exact misuse is this test supposed to reject? 断言:这个测试旨在拒绝哪种具体的误用?
- Reachability: Can the compiler reach that boundary without an unrelated error firing first? 可达性:编译器能否在不触发无关错误的情况下触及该边界?
- Diagnostic: Is the observed error actually about the property I care about? 诊断:观察到的错误是否确实与我关心的属性有关?
- Currentness: Does the test still match the current API, type shape, imports, and trait surface? 时效性:测试是否仍然匹配当前的 API、类型结构、导入和 trait 表面?
This sounds obvious in hindsight. It becomes less obvious after a test suite has lived through a few rounds of refactoring. A stale import can fail. Old constructor syntax can fail. Missing generic information can fail. An operation that used to be forbidden may even become valid later. The word “fail” does not tell me which of those happened. 事后看来这显而易见。但在测试套件经历了几轮重构后,情况就不那么明显了。过时的导入会导致失败,旧的构造函数语法会导致失败,缺失的泛型信息会导致失败。甚至曾经被禁止的操作后来可能变得合法。而“失败”这个词并不能告诉我究竟发生了哪种情况。
Runtime failure is a different boundary
运行时失败是另一个边界
One other case was a useful sanity check. Something like this: 另一个案例是一个有用的健全性检查。类似这样:
SomeType::new(0).unwrap();
might panic at runtime if new(0) returns an error or None. But the program itself can still be perfectly valid Rust and compile successfully. So that is not compile-fail evidence. It belongs to runtime behavior. Again, obvious once you say it out loud. But mixing compile-time and runtime expectations is surprisingly easy when old tests and old comments have been copied forward for long enough.
如果 new(0) 返回错误或 None,它可能会在运行时 panic。但程序本身仍然是完全合法的 Rust 代码,可以成功编译。所以这不能作为编译失败的证据,它属于运行时行为。同样,说出来很明显,但当旧的测试和注释被长期复制沿用时,混淆编译时和运行时的预期是极其容易的。
The bigger lesson was about verification code
更大的教训在于验证代码
What stuck with me was not the individual Rust errors. It was the fact that verification code can drift while production code keeps moving. Tests encode assumptions too. They encode assumptions about type shape, visibility, crate paths, trait surfaces, invariants, and what the API is supposed to forbid. Those assumptions can go stale. 让我印象深刻的不是具体的 Rust 错误,而是验证代码可能会随着生产代码的演进而产生偏差。测试也编码了假设。它们编码了关于类型结构、可见性、crate 路径、trait 表面、不变量以及 API 应该禁止什么的假设。这些假设可能会过时。
A missing test is easy to notice. A test that still “works” can be more misleading because it gives you confidence without checking the contract you think it checks. That applies beyond compile-fail tests. Property tests can model an old semantic rule. Fuzz targets can exercise an obsolete API. Snapshot-based tests can preserve an error that no longer represents the intended boundary. 缺失的测试很容易被发现。而一个仍然“能跑”的测试反而更具误导性,因为它在没有检查你认为它在检查的契约的情况下,给了你虚假的信心。这不仅适用于编译失败测试。属性测试(Property tests)可能在模拟旧的语义规则,模糊测试(Fuzz targets)可能在测试过时的 API,基于快照的测试可能保留了一个不再代表预期边界的错误。
So I have become a lot more suspicious of verification artifacts during hardening work. I do not just ask whether the test suite passes anymore. For negative tests, I also ask: Why did the compiler say no? If the answer is not the boundary I meant to protect, the test is not done yet. 因此,在加固工作期间,我对验证工件变得更加怀疑。我不再仅仅询问测试套件是否通过。对于负面测试,我还会问:编译器为什么说“不”?如果答案不是我想要保护的边界,那么这个测试就还没完成。