Skip to main content

stern4rust/rules/testing/
test_naming_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use syn::Attribute;
6use syn::Item;
7use syn::ItemFn;
8use syn::parse_file;
9
10use crate::reporting::offence::Offence;
11use crate::rule::Rule;
12use crate::source_file::SourceFile;
13
14// A test is named `<method>_<conditions>_<result>`.
15//
16// This rule reads the name and nothing else, which is a deliberate retreat from
17// where it started. Earlier versions tried to verify that the name's leading
18// part was the method actually under test -- by looking for it in the body, then
19// through the test file's helpers transitively, then against the mirrored source
20// file. All three were measured across the workspace and all three produced
21// confident wrong answers on correct code: tests of derived operators (`a < b`
22// calls no named function), tests of derived methods (`from_str` on a
23// `#[derive(ValueEnum)]` enum is not a `fn` anywhere), and names reachable only
24// through whatever a wide setup helper happened to touch.
25//
26// The question those versions were reaching for -- is this thing actually
27// tested -- is answered from the other end by `tested-public-api`, which starts
28// from the declared entry points instead of guessing at intent. This rule keeps
29// the part that can be checked without ever being wrong: a name with fewer than
30// three parts cannot carry a method, a condition and a result.
31pub struct TestNamingRule;
32
33impl TestNamingRule {
34    pub const MINIMUM_PARTS: usize = 3;
35    pub const REGISTRIES: [&'static str; 2] = ["all_tests.rs", "mod.rs"];
36    pub const TESTS_ROOT: &'static str = "tests/";
37
38    pub fn new() -> Self {
39        Self
40    }
41
42    fn applies_to(file: &SourceFile) -> bool {
43        let path = file.relative_path();
44        path.starts_with(Self::TESTS_ROOT)
45            && !path
46                .rsplit('/')
47                .next()
48                .is_some_and(|name| Self::REGISTRIES.contains(&name))
49    }
50
51    fn is_test(attrs: &[Attribute]) -> bool {
52        attrs.iter().any(|attr| {
53            attr.path()
54                .segments
55                .last()
56                .is_some_and(|segment| segment.ident == "test")
57        })
58    }
59
60    fn offence(&self, file: &SourceFile, function: &ItemFn) -> Option<Offence> {
61        let name = function.sig.ident.to_string();
62        if name.split('_').count() >= Self::MINIMUM_PARTS {
63            return None;
64        }
65        Some(self.shape_offence(file, &name, function.sig.ident.span().start().line))
66    }
67
68    fn shape_offence(&self, file: &SourceFile, name: &str, line: usize) -> Offence {
69        Offence::new(
70            file.relative_path(),
71            line,
72            self.name(),
73            format!(
74                "`{name}` has fewer than {} parts, so it cannot say what it calls, under what \
75                 conditions, and with what result",
76                Self::MINIMUM_PARTS
77            ),
78            format!(
79                "rename it `<method>_<conditions>_<result>`, starting with the method {name} calls"
80            ),
81        )
82        .with_subject(name)
83    }
84}
85
86impl Default for TestNamingRule {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl Rule for TestNamingRule {
93    fn name(&self) -> &'static str {
94        "test-naming"
95    }
96
97    fn check(&self, file: &SourceFile) -> Vec<Offence> {
98        if !Self::applies_to(file) {
99            return Vec::new();
100        }
101        let Ok(syntax) = parse_file(&file.contents()) else {
102            return Vec::new();
103        };
104        syntax
105            .items
106            .iter()
107            .filter_map(|item| match item {
108                Item::Fn(function) if Self::is_test(&function.attrs) => Some(function),
109                _ => None,
110            })
111            .filter_map(|function| self.offence(file, function))
112            .collect()
113    }
114
115    fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
116        Vec::new()
117    }
118
119    fn requirement(&self) -> Option<&'static str> {
120        None
121    }
122
123    fn is_configured(&self) -> bool {
124        true
125    }
126}