Testingexpect-panic
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
[lint] preview = true select = ["RUF990"]panic.py:
1虽然只有 15 行,但每一行都有明确的测试意图,逐段拆解: | 元素 | 内容 | 作用 | |---|---|---| | H1 标题 | `# Testing \`expect-panic\`` | mdtest 以标题切分测试段(section),每个标题对应一个独立的子测试 | | HTML 注释指令 | `<!-- expect-panic: This is a fake panic for testing -->` | 声明“本子测试预期会 panic”,冒号后的文本是**必须出现在 panic 消息中的子串** | | TOML 配置块 | `preview = true` + `select = ["RUF990"]` | 为本次 Lint 启用 preview 模式,并只选择 RUF990 这一条测试规则 | | Python 代码块 | 标题为 `panic.py`,内容为 `1` | 声明一个嵌入式虚拟文件 `panic.py`;文件名是触发 panic 的关键,文件内容本身无关紧要 | 两个关键设计点值得单独说明: 1. **`preview = true` 不可省略**。RUF990 对应的规则元数据标注为 `preview_since = "0.0.0"`(见下文),意味着它处于 preview 状态、永远不属于 stable 集合;如果不开启 preview,`select = ["RUF990"]` 根本不会让该规则生效,panic 也就无从谈起。 2. **文件名 `panic.py` 就是测试的“开关”**。规则实现里通过判断被 Lint 的文件名是否以 `panic.py` 结尾来决定是否触发断言失败(即 panic),因此代码块标题写成 `panic.py` 是夹具生效的前提,而文件内容 `1` 只是一个合法的占位语句。 ## `expect-panic` 指令的解析:mdtest 的 HTML 注释指令系统 mdtest 是 Ruff 自研的 Markdown 测试框架,核心实现位于 [crates/mdtest/src/parser.rs](https://link.gitcode.com/i/6505347e46360239ba513dc1ec1101d3)。在该文件的 `parse_impl` 中([parser.rs#L591-L653](https://link.gitcode.com/i/6505347e46360239ba513dc1ec1101d3#L591-L653)),解析器识别以 `<!-- ... -->` 包裹的 HTML 注释,并将其中 `指令名: 值` 形式的片段映射为 `MdtestDirective`: ```rust const SECTION_CONFIG_SNAPSHOT: &str = "snapshot-diagnostics"; const SECTION_CONFIG_PULLTYPES: &str = "pull-types:skip"; const SECTION_CONFIG_EXPECT_PANIC: &str = "expect-panic"; match directive { SECTION_CONFIG_SNAPSHOT => { /* 必须不带值 */ } SECTION_CONFIG_PULLTYPES => { /* 必须不带值 */ } SECTION_CONFIG_EXPECT_PANIC => { self.process_mdtest_directive(MdtestDirective::ExpectPanic, value)?; } _ => { /* 非白名单注释直接报错,防止拼写错误 */ } }可以看到与夹具配套的三条硬约束:
expect-panic是允许携带值的指令(值就是预期 panic 消息子串),而snapshot-diagnostics、pull-types:skip等指令必须不带值,否则解析阶段就报错;- 所有 HTML 注释都必须落在指令白名单或
fmt:on/fmt:off之内,否则直接bail!,避免夹具里出现“拼错了的指令”被静默忽略; - 解析结果最终可通过 parser.rs#L162-L164 的
should_expect_panic()读取,返回Option<&str>:Some(子串)表示预期 panic 且需匹配子串,None表示预期 panic 但不校验消息。
指令语义的官方说明在 crates/ty_test/README.md 中(ruff_mdtest 与 ty_test 共用同一套 mdtest 内核):
冒号后的文本是必须出现在 panic 消息中的子串,该消息是可选的。
主角 RUF990:一条专门制造 panic 的测试规则
夹具里select = ["RUF990"]选中的规则定义在 crates/ruff_linter/src/rules/ruff/rules/test_rules.rs。文件头部注释说明了这一组“假规则”的定位:
/// Fake rules for testing Ruff's behavior /// /// All of these rules should be assigned to the RUF9XX codes.PanicyTestRule位于 test_rules.rs#L495-L529:
#[derive(ViolationMetadata)] #[violation_metadata(preview_since = "0.0.0", category = Category::Testing)] pub(crate) struct PanicyTestRule; impl Violation for PanicyTestRule { const FIX_AVAILABILITY: FixAvailability = FixAvailability::None; #[derive_message_formats] fn message(&self) -> String { "If you see this, maybe panic!".to_string() } } impl TestRule for PanicyTestRule { fn diagnostic(_locator: &Locator, _comment_ranges: &CommentRanges, context: &LintContext) { assert!( !context.source_file().name().ends_with("panic.py"), "This is a fake panic for testing." ); } }这里有四个与夹具严格咬合的细节:
- 触发条件:
diagnostic中唯一的逻辑是对被 Lint 文件名断言!ends_with("panic.py")。mdtest 会把嵌入式代码块物化进内存文件系统,本夹具声明的文件恰好叫panic.py,断言随即失败,Rust 的assert!抛出 panic,panic 消息正是"This is a fake panic for testing."—— 与夹具中expect-panic指令携带的子串逐字对应。 - preview 元数据:
preview_since = "0.0.0"表明该规则自始处于 preview 状态,因此夹具必须写preview = true,否则规则不会进入启用集合。 - 规则编码注册:RUF990 到
PanicyTestRule的映射写在 crates/ruff_linter/src/codes.rs#L1253:(Ruff, "990") => rules::ruff::rules::PanicyTestRule;规则统一登记在TEST_RULES常量(test_rules.rs#L37-L51)中,并在 linter.rs#L325-L327 的测试规则分派处按Rule::PanicyTestRule调用diagnostic。 - 为什么不直接 report 一个诊断:这条规则的目的不是报告违规,而是把“Lint 进程崩溃”本身当作被测行为——mdtest 需要一条可控、可复现、消息固定的 panic 源,来验证“预期 panic”断言机制本身是正确的。
运行链路:panic 如何被捕获并与预期消息比对
测试入口:datatest 扫描所有.md夹具
crates/ruff_mdtest/tests/mdtest.rs 用datatest_stable把../ruff_linter/resources/mdtest目录下所有*.md文件各自注册成一个测试用例,本夹具对应的测试名即test-rules/panicy-test-rule.md:
datatest_stable::harness! { { test = mdtest, root = "../ruff_linter/resources/mdtest", pattern = r"\.md$" }, }执行:把 Markdown 变成一次 Lint 调用
crates/ruff_mdtest/src/lib.rs#L26-L51 的run函数负责单个夹具的执行:先调用 mdtest 解析器把 Markdown 切成MarkdownTestSuite,再基于ruff_db建立测试数据库;每个子测试在 ruff_mdtest/src/lib.rs#L53-L80 的run_test中,将嵌入式代码块(py/pyi/ipynb/toml语言块)物化到内存文件系统/src下,解析 TOML 配置块为Options,然后对目标文件执行 Lint。夹具中select = ["RUF990"]即在此处生效,最终走到PanicyTestRule::diagnostic触发 panic。
捕获与断言:attempt_test与check_panic
mdtest 内核(crates/mdtest/src/lib.rs)把“执行测试函数”包裹在 panic 捕获里。lib.rs#L591-L605 的attempt_test借助ruff_db::panic模块的catch_unwind:
/// Run a function over an embedded test file, catching any panics that occur in the process. pub fn attempt_test<'a, T, F>( test_fn: F, test_file: &'a TestFile<'a>, ) -> Result<T, AttemptTestError<'a>> where F: FnOnce(File) -> T + std::panic::UnwindSafe, { catch_unwind(|| test_fn(test_file.file)).map_err(|info| AttemptTestError { info, test_file }) }随后 lib.rs#L673-L702 的check_panic完成双向断言,这正是expect-panic指令的完整语义:
pub fn check_panic<C>(test: &MarkdownTest<'_, '_, C>, panic_info: Option<PanicError>) { match panic_info { Some(panic_info) => { // 发生了 panic:若指令携带预期子串,则 panic 消息必须包含它 if let Some(expected_message) = expected_message { assert!(message.contains(expected_message), /* ... */); } } None => { // 未发生 panic:但指令声明了 expect-panic → 测试失败 panic!("Test `{}` is expected to panic but it didn't.", test.name()); } } }结合本夹具可以读出三种失败路径:
- panic 消息不匹配:规则改了断言文案,但夹具里的
expect-panic子串没跟着改,message.contains(expected_message)失败; - 预期 panic 却没有 panic:例如把代码块标题从
panic.py改成别的名字,assert!不再触发,测试会以“expected to panic but it didn't”失败——这条约束保证了夹具不会“假绿”; - 意外 panic(没有
expect-panic指令却 panic):AttemptTestError::into_file_failures(lib.rs#L612-L664)会把 panic 位置、payload 以及 backtrace(可用RUST_BACKTRACE=1开启)格式化为指向夹具对应行的诊断输出,并在提示中说明如何获取回溯。
运行与调试该测试
由于每个.md夹具都是 datatest 注册的一个用例,验证方式即常规cargo test:
# 运行整个 mdtest 套件 cargo test -p ruff_mdtest # 只跑本夹具(MDTEST_TEST_FILTER 按测试名做子串过滤) MDTEST_TEST_FILTER="panicy-test-rule.md" cargo test -p ruff_mdtest【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考