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