Skip to main content

testing_conventions/
lint.rs

1//! Integration-test lints (issue #19; rules #48–#52) — the `integration lint`
2//! command.
3//!
4//! A *lint* here is a deterministic style/mechanism check on test code, as
5//! opposed to the structural `location` / `coverage` rules. This module hosts
6//! the mocking mechanism & style lints; more lints will join them under the
7//! same command.
8//!
9//! Detection is AST-based: each Python test file is parsed with
10//! `rustpython_parser` and the tree is walked.
11//!
12//! Implemented lints:
13//! - **`no-monkeypatch`** (#49): a test/fixture function that declares the
14//!   `monkeypatch` parameter (pytest's fixture). Patch with `unittest.mock`
15//!   wrapped in a `pytest.fixture` instead.
16
17use std::path::{Path, PathBuf};
18
19use anyhow::{anyhow, Context, Result};
20use rustpython_parser::ast::{self, Arguments, Stmt};
21use rustpython_parser::text_size::{TextRange, TextSize};
22use rustpython_parser::Parse;
23
24/// A single lint violation found in a test file.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Violation {
27    /// File the violation was found in.
28    pub file: PathBuf,
29    /// 1-based line number of the offending construct.
30    pub line: usize,
31    /// Short lint identifier (e.g. `no-monkeypatch`).
32    pub rule: &'static str,
33    /// Human-readable explanation.
34    pub message: String,
35}
36
37/// Scan the Python test files under `root` and return every lint violation,
38/// sorted by `(file, line)` for deterministic output.
39///
40/// A *Python test file* is `*_test.py`, the legacy `test_*.py`, or
41/// `conftest.py` (where fixtures live). Each is parsed and its function
42/// definitions — at any nesting depth (`pytest-describe` nests them, classes
43/// hold them) — are checked against the lints. A file that cannot be read or
44/// parsed is an error.
45pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
46    let root = root.as_ref();
47    let mut files = Vec::new();
48    collect_python_test_files(root, &mut files)?;
49    files.sort();
50
51    let mut violations = Vec::new();
52    for file in &files {
53        let source = std::fs::read_to_string(file)
54            .with_context(|| format!("reading test file `{}`", file.display()))?;
55        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
56            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
57        check_suite(&suite, file, &source, &mut violations);
58    }
59
60    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
61    Ok(violations)
62}
63
64/// Walk a block of statements, descending into the bodies that can hold a test
65/// function — nested `def`s (`pytest-describe`) and classes — and check every
66/// function definition's parameters.
67fn check_suite(stmts: &[Stmt], file: &Path, source: &str, out: &mut Vec<Violation>) {
68    for stmt in stmts {
69        match stmt {
70            Stmt::FunctionDef(f) => {
71                check_arguments(&f.args, f.range, file, source, out);
72                check_suite(&f.body, file, source, out);
73            }
74            Stmt::AsyncFunctionDef(f) => {
75                check_arguments(&f.args, f.range, file, source, out);
76                check_suite(&f.body, file, source, out);
77            }
78            Stmt::ClassDef(c) => check_suite(&c.body, file, source, out),
79            _ => {}
80        }
81    }
82}
83
84/// `no-monkeypatch` (#49): flag a function that declares the `monkeypatch`
85/// parameter — the definitive signal that a test/fixture uses pytest's
86/// `monkeypatch` fixture.
87fn check_arguments(
88    args: &Arguments,
89    range: TextRange,
90    file: &Path,
91    source: &str,
92    out: &mut Vec<Violation>,
93) {
94    let takes_monkeypatch = args
95        .posonlyargs
96        .iter()
97        .chain(args.args.iter())
98        .chain(args.kwonlyargs.iter())
99        .any(|arg| arg.def.arg.as_str() == "monkeypatch")
100        || vararg_is_monkeypatch(&args.vararg)
101        || vararg_is_monkeypatch(&args.kwarg);
102
103    if takes_monkeypatch {
104        out.push(Violation {
105            file: file.to_path_buf(),
106            line: line_of(source, range.start()),
107            rule: "no-monkeypatch",
108            message:
109                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead"
110                    .to_string(),
111        });
112    }
113}
114
115/// `true` when a `*args` / `**kwargs` arg is named `monkeypatch`.
116fn vararg_is_monkeypatch(arg: &Option<Box<ast::Arg>>) -> bool {
117    arg.as_ref()
118        .is_some_and(|arg| arg.arg.as_str() == "monkeypatch")
119}
120
121/// The 1-based line containing byte `offset` in `source`.
122fn line_of(source: &str, offset: TextSize) -> usize {
123    let offset = (u32::from(offset) as usize).min(source.len());
124    source.as_bytes()[..offset]
125        .iter()
126        .filter(|&&byte| byte == b'\n')
127        .count()
128        + 1
129}
130
131/// Recursively collect every Python test file under `dir` into `out`.
132fn collect_python_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
133    let entries =
134        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
135    for entry in entries {
136        let path = entry
137            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
138            .path();
139        if path.is_dir() {
140            collect_python_test_files(&path, out)?;
141        } else if is_python_test_file(&path) {
142            out.push(path);
143        }
144    }
145    Ok(())
146}
147
148/// `true` for a file the lints scan: `*_test.py`, legacy `test_*.py`, or
149/// `conftest.py`.
150fn is_python_test_file(path: &Path) -> bool {
151    let name = path
152        .file_name()
153        .and_then(|n| n.to_str())
154        .unwrap_or_default();
155    name == "conftest.py"
156        || name.ends_with("_test.py")
157        || (name.starts_with("test_") && name.ends_with(".py"))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn recognizes_python_test_files() {
166        assert!(is_python_test_file(Path::new("widget_test.py")));
167        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
168        assert!(is_python_test_file(Path::new("test_widget.py")));
169        assert!(is_python_test_file(Path::new("conftest.py")));
170    }
171
172    #[test]
173    fn ignores_non_test_files() {
174        assert!(!is_python_test_file(Path::new("widget.py")));
175        assert!(!is_python_test_file(Path::new("conftest.pyi")));
176        assert!(!is_python_test_file(Path::new("README.md")));
177        assert!(!is_python_test_file(Path::new("testing.py")));
178    }
179
180    #[test]
181    fn line_of_counts_newlines() {
182        let src = "a\nb\nc\n";
183        assert_eq!(line_of(src, TextSize::from(0)), 1);
184        assert_eq!(line_of(src, TextSize::from(2)), 2);
185        assert_eq!(line_of(src, TextSize::from(4)), 3);
186    }
187}