Skip to content

Commit 2adaa5f

Browse files
committed
feat: implement test without panic check
1 parent 5678531 commit 2adaa5f

File tree

8 files changed

+235
-2
lines changed

8 files changed

+235
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6004,6 +6004,7 @@ Released 2018-09-13
60046004
[`temporary_assignment`]: https://rust-lang.github.io/rust-clippy/master/index.html#temporary_assignment
60056005
[`temporary_cstring_as_ptr`]: https://rust-lang.github.io/rust-clippy/master/index.html#temporary_cstring_as_ptr
60066006
[`test_attr_in_doctest`]: https://rust-lang.github.io/rust-clippy/master/index.html#test_attr_in_doctest
6007+
[`test_without_fail_case`]: https://rust-lang.github.io/rust-clippy/master/index.html#test_without_fail_case
60076008
[`tests_outside_test_module`]: https://rust-lang.github.io/rust-clippy/master/index.html#tests_outside_test_module
60086009
[`thread_local_initializer_can_be_made_const`]: https://rust-lang.github.io/rust-clippy/master/index.html#thread_local_initializer_can_be_made_const
60096010
[`to_digit_is_some`]: https://rust-lang.github.io/rust-clippy/master/index.html#to_digit_is_some

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
A collection of lints to catch common mistakes and improve your [Rust](https://github.com/rust-lang/rust) code.
77

8-
[There are over 700 lints included in this crate!](https://rust-lang.github.io/rust-clippy/master/index.html)
8+
[There are over 750 lints included in this crate!](https://rust-lang.github.io/rust-clippy/master/index.html)
99

1010
Lints are divided into categories, each with a default [lint level](https://doc.rust-lang.org/rustc/lints/levels.html).
1111
You can choose how much Clippy is supposed to ~~annoy~~ help you by changing the lint level by category.

book/src/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
A collection of lints to catch common mistakes and improve your
77
[Rust](https://github.com/rust-lang/rust) code.
88

9-
[There are over 700 lints included in this crate!](https://rust-lang.github.io/rust-clippy/master/index.html)
9+
[There are over 750 lints included in this crate!](https://rust-lang.github.io/rust-clippy/master/index.html)
1010

1111
Lints are divided into categories, each with a default [lint
1212
level](https://doc.rust-lang.org/rustc/lints/levels.html). You can choose how

clippy_lints/src/declared_lints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,7 @@ pub static LINTS: &[&crate::LintInfo] = &[
691691
crate::swap_ptr_to_ref::SWAP_PTR_TO_REF_INFO,
692692
crate::tabs_in_doc_comments::TABS_IN_DOC_COMMENTS_INFO,
693693
crate::temporary_assignment::TEMPORARY_ASSIGNMENT_INFO,
694+
crate::test_without_fail_case::TEST_WITHOUT_FAIL_CASE_INFO,
694695
crate::tests_outside_test_module::TESTS_OUTSIDE_TEST_MODULE_INFO,
695696
crate::to_digit_is_some::TO_DIGIT_IS_SOME_INFO,
696697
crate::to_string_trait_impl::TO_STRING_TRAIT_IMPL_INFO,

clippy_lints/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ mod swap;
346346
mod swap_ptr_to_ref;
347347
mod tabs_in_doc_comments;
348348
mod temporary_assignment;
349+
mod test_without_fail_case;
349350
mod tests_outside_test_module;
350351
mod to_digit_is_some;
351352
mod to_string_trait_impl;
@@ -949,5 +950,6 @@ pub fn register_lints(store: &mut rustc_lint::LintStore, conf: &'static Conf) {
949950
store.register_late_pass(move |_| Box::new(unused_trait_names::UnusedTraitNames::new(conf)));
950951
store.register_late_pass(|_| Box::new(manual_ignore_case_cmp::ManualIgnoreCaseCmp));
951952
store.register_late_pass(|_| Box::new(unnecessary_literal_bound::UnnecessaryLiteralBound));
953+
store.register_late_pass(|_| Box::new(test_without_fail_case::TestWithoutFailCase));
952954
// add lints here, do not remove this comment, it's used in `new_lint`
953955
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use clippy_utils::diagnostics::span_lint_and_then;
2+
use clippy_utils::macros::{is_panic, root_macro_call_first_node};
3+
use clippy_utils::ty::is_type_diagnostic_item;
4+
use clippy_utils::visitors::Visitable;
5+
use clippy_utils::{is_in_test_function, method_chain_args};
6+
use rustc_hir::intravisit::{self, FnKind, Visitor};
7+
use rustc_hir::{AnonConst, Body, Expr, FnDecl, Item, ItemKind};
8+
use rustc_lint::{LateContext, LateLintPass};
9+
use rustc_middle::hir::nested_filter;
10+
use rustc_middle::ty;
11+
use rustc_session::declare_lint_pass;
12+
use rustc_span::{Span, sym};
13+
14+
declare_clippy_lint! {
15+
/// ### What it does
16+
/// Triggers when a testing function (marked with the `#[test]` attribute) does not have a way to fail.
17+
///
18+
/// ### Why restrict this?
19+
/// If a test does not have a way to fail, the developer might be getting false positives from their test suites.
20+
/// The idiomatic way of using test functions should be such that they actually can fail in an erroneous state.
21+
///
22+
/// ### Example
23+
/// ```no_run
24+
/// #[test]
25+
/// fn my_cool_test() {
26+
/// // [...]
27+
/// }
28+
///
29+
/// #[cfg(test)]
30+
/// mod tests {
31+
/// // [...]
32+
/// }
33+
///
34+
/// ```
35+
/// Use instead:
36+
/// ```no_run
37+
/// #[cfg(test)]
38+
/// mod tests {
39+
/// #[test]
40+
/// fn my_cool_test() {
41+
/// // [...]
42+
/// }
43+
/// }
44+
/// ```
45+
#[clippy::version = "1.70.0"]
46+
pub TEST_WITHOUT_FAIL_CASE,
47+
restriction,
48+
"A test function is outside the testing module."
49+
}
50+
51+
declare_lint_pass!(TestWithoutFailCase => [TEST_WITHOUT_FAIL_CASE]);
52+
53+
/*
54+
impl<'tcx> LateLintPass<'tcx> for TestWithoutFailCase {
55+
fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx rustc_hir::Item<'_>) {
56+
if let rustc_hir::ItemKind::Fn(sig, _, body_id) = item.kind {
57+
58+
}
59+
if is_in_test_function(cx.tcx, item.hir_id()) {
60+
let typck = cx.tcx.typeck(item.id.owner_id.def_id);
61+
let find_panic_visitor = FindPanicUnwrap::find_span(cx, typck, item.body)
62+
span_lint(cx, TEST_WITHOUT_FAIL_CASE, item.span, "test function cannot panic");
63+
}
64+
}
65+
}
66+
*/
67+
68+
impl<'tcx> LateLintPass<'tcx> for TestWithoutFailCase {
69+
fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx Item<'tcx>) {
70+
if let ItemKind::Fn(_, _, body_id) = item.kind
71+
&& is_in_test_function(cx.tcx, item.hir_id())
72+
{
73+
let body = cx.tcx.hir().body(body_id);
74+
let typ = cx.tcx.typeck(item.owner_id);
75+
let panic_span = FindPanicUnwrap::find_span(cx, typ, body);
76+
if panic_span.is_none() {
77+
// No way to panic for this test.
78+
#[expect(clippy::collapsible_span_lint_calls, reason = "rust-clippy#7797")]
79+
span_lint_and_then(
80+
cx,
81+
TEST_WITHOUT_FAIL_CASE,
82+
item.span,
83+
"this function marked with #[test] has no way to fail",
84+
|diag| {
85+
diag.note("make sure that something is checked in this test");
86+
},
87+
);
88+
}
89+
}
90+
}
91+
}
92+
93+
struct FindPanicUnwrap<'a, 'tcx> {
94+
cx: &'a LateContext<'tcx>,
95+
is_const: bool,
96+
panic_span: Option<Span>,
97+
typeck_results: &'tcx ty::TypeckResults<'tcx>,
98+
}
99+
100+
impl<'a, 'tcx> FindPanicUnwrap<'a, 'tcx> {
101+
pub fn find_span(
102+
cx: &'a LateContext<'tcx>,
103+
typeck_results: &'tcx ty::TypeckResults<'tcx>,
104+
body: impl Visitable<'tcx>,
105+
) -> Option<(Span, bool)> {
106+
let mut vis = Self {
107+
cx,
108+
is_const: false,
109+
panic_span: None,
110+
typeck_results,
111+
};
112+
body.visit(&mut vis);
113+
vis.panic_span.map(|el| (el, vis.is_const))
114+
}
115+
}
116+
117+
impl<'a, 'tcx> Visitor<'tcx> for FindPanicUnwrap<'a, 'tcx> {
118+
type NestedFilter = nested_filter::OnlyBodies;
119+
120+
fn visit_expr(&mut self, expr: &'tcx Expr<'_>) {
121+
if self.panic_span.is_some() {
122+
return;
123+
}
124+
125+
if let Some(macro_call) = root_macro_call_first_node(self.cx, expr) {
126+
if is_panic(self.cx, macro_call.def_id)
127+
|| matches!(
128+
self.cx.tcx.item_name(macro_call.def_id).as_str(),
129+
"assert" | "assert_eq" | "assert_ne"
130+
)
131+
{
132+
self.is_const = self.cx.tcx.hir().is_inside_const_context(expr.hir_id);
133+
self.panic_span = Some(macro_call.span);
134+
}
135+
}
136+
137+
// check for `unwrap` and `expect` for both `Option` and `Result`
138+
if let Some(arglists) = method_chain_args(expr, &["unwrap"]).or(method_chain_args(expr, &["expect"])) {
139+
let receiver_ty = self.typeck_results.expr_ty(arglists[0].0).peel_refs();
140+
if is_type_diagnostic_item(self.cx, receiver_ty, sym::Option)
141+
|| is_type_diagnostic_item(self.cx, receiver_ty, sym::Result)
142+
{
143+
self.panic_span = Some(expr.span);
144+
}
145+
}
146+
147+
// and check sub-expressions
148+
intravisit::walk_expr(self, expr);
149+
}
150+
151+
// Panics in const blocks will cause compilation to fail.
152+
fn visit_anon_const(&mut self, _: &'tcx AnonConst) {}
153+
154+
fn nested_visit_map(&mut self) -> Self::Map {
155+
self.cx.tcx.hir()
156+
}
157+
}

tests/ui/test_without_fail_case.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#![allow(unused)]
2+
#![allow(clippy::tests_outside_test_module)]
3+
#![allow(clippy::unnecessary_literal_unwrap)]
4+
#![warn(clippy::test_without_fail_case)]
5+
6+
// Should lint
7+
#[test]
8+
fn test_without_fail() {
9+
// This test cannot fail.
10+
let x = 5;
11+
let y = x + 2;
12+
println!("y: {}", y);
13+
}
14+
//~^ ERROR: this function marked with #[test] does not have a way to fail.
15+
//~^ NOTE: Ensure that something is being tested and asserted by this test.
16+
17+
// Should not lint
18+
#[test]
19+
fn test_with_fail() {
20+
// This test can fail.
21+
assert_eq!(1 + 1, 2);
22+
}
23+
24+
// Should not lint
25+
#[test]
26+
fn test_implicit_panic() {
27+
implicit_panic()
28+
}
29+
30+
fn implicit_panic() {
31+
panic!("this is an implicit panic");
32+
}
33+
34+
fn implicit_unwrap() {
35+
let val: Option<u32> = None;
36+
let _ = val.unwrap();
37+
}
38+
39+
fn implicit_assert() {
40+
assert_eq!(1, 2)
41+
}
42+
43+
fn main() {
44+
// Non-test code
45+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
error: this function marked with #[test] has no way to fail
2+
--> tests/ui/test_without_fail_case.rs:8:1
3+
|
4+
LL | / fn test_without_fail() {
5+
LL | | // This test cannot fail.
6+
LL | | let x = 5;
7+
LL | | let y = x + 2;
8+
LL | | println!("y: {}", y);
9+
LL | | }
10+
| |_^
11+
|
12+
= note: make sure that something is checked in this test
13+
= note: `-D clippy::test-without-fail-case` implied by `-D warnings`
14+
= help: to override `-D warnings` add `#[allow(clippy::test_without_fail_case)]`
15+
16+
error: this function marked with #[test] has no way to fail
17+
--> tests/ui/test_without_fail_case.rs:26:1
18+
|
19+
LL | / fn test_implicit_panic() {
20+
LL | | implicit_panic()
21+
LL | | }
22+
| |_^
23+
|
24+
= note: make sure that something is checked in this test
25+
26+
error: aborting due to 2 previous errors
27+

0 commit comments

Comments
 (0)