Skip to main content

testing_conventions/
location.rs

1//! Unit-test location/naming check for Python sources (issue #15).
2//!
3//! The convention (README "Location & Naming"; `internals/python/testing.md`):
4//! a Python source file `foo.py` is unit-tested by a colocated `foo_test.py`.
5//! [`missing_unit_tests`] walks a directory tree and returns every source file
6//! that has no such sibling — an "orphan". Files that are themselves tests
7//! (`*_test.py`) are what the check looks *for*, never subjects, and the
8//! package marker (`__init__.py`) is exempt.
9
10use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14
15/// The extension that marks a Python file.
16const PY_EXTENSION: &str = "py";
17/// The stem suffix that marks a file as a unit test: `foo` → `foo_test`.
18const TEST_STEM_SUFFIX: &str = "_test";
19/// The package marker, which is never a unit-test subject.
20const PACKAGE_MARKER: &str = "__init__.py";
21
22/// Walk `root` recursively and return every Python source file that has no
23/// colocated `<stem>_test.py`, sorted for deterministic output.
24///
25/// A file whose stem ends in `_test` is itself a test and is never treated as a
26/// subject; every other `*.py` file is a subject and must have its colocated
27/// test sibling. Returns an error if the tree under `root` cannot be read.
28pub fn missing_unit_tests(root: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
29    let mut python_files = Vec::new();
30    collect_python_files(root.as_ref(), &mut python_files)?;
31
32    // Every `*.py` path we found, so a subject's expected twin is a lookup
33    // rather than a second pass over the filesystem.
34    let present: HashSet<&Path> = python_files.iter().map(PathBuf::as_path).collect();
35
36    let mut orphans: Vec<PathBuf> = Vec::new();
37    for source in &python_files {
38        if is_test_file(source) || is_exempt(source) {
39            continue;
40        }
41        if !present.contains(expected_test_path(source).as_path()) {
42            orphans.push(source.clone());
43        }
44    }
45    orphans.sort();
46    Ok(orphans)
47}
48
49/// Recursively collect every `*.py` file under `dir` into `out`.
50fn collect_python_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
51    let entries =
52        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
53    for entry in entries {
54        let path = entry
55            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
56            .path();
57        if path.is_dir() {
58            collect_python_files(&path, out)?;
59        } else if is_python_source(&path) {
60            out.push(path);
61        }
62    }
63    Ok(())
64}
65
66/// `true` for a file with a `.py` extension.
67fn is_python_source(path: &Path) -> bool {
68    path.extension().and_then(|ext| ext.to_str()) == Some(PY_EXTENSION)
69}
70
71/// `true` when `path` is itself a unit test (`*_test.py`), never a subject.
72fn is_test_file(path: &Path) -> bool {
73    stem_of(path).ends_with(TEST_STEM_SUFFIX)
74}
75
76/// `true` for the package marker (`__init__.py`), which never needs a test.
77fn is_exempt(path: &Path) -> bool {
78    path.file_name().and_then(|name| name.to_str()) == Some(PACKAGE_MARKER)
79}
80
81/// The colocated test a source is expected to have: `foo.py` → `foo_test.py`.
82fn expected_test_path(source: &Path) -> PathBuf {
83    source.with_file_name(format!(
84        "{}{}.{}",
85        stem_of(source),
86        TEST_STEM_SUFFIX,
87        PY_EXTENSION
88    ))
89}
90
91/// The file stem (the name without its extension), lossily decoded.
92fn stem_of(path: &Path) -> String {
93    path.file_stem()
94        .map(|stem| stem.to_string_lossy().into_owned())
95        .unwrap_or_default()
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn recognizes_python_sources_by_extension() {
104        assert!(is_python_source(Path::new("a.py")));
105        assert!(is_python_source(Path::new("pkg/widget.py")));
106        assert!(!is_python_source(Path::new("a.pyi")));
107        assert!(!is_python_source(Path::new("a.txt")));
108        assert!(!is_python_source(Path::new("README")));
109    }
110
111    #[test]
112    fn recognizes_test_files_by_stem_suffix() {
113        assert!(is_test_file(Path::new("widget_test.py")));
114        assert!(is_test_file(Path::new("pkg/helper_test.py")));
115        assert!(!is_test_file(Path::new("widget.py")));
116        assert!(!is_test_file(Path::new("pkg/helper.py")));
117    }
118
119    #[test]
120    fn exempts_the_package_marker() {
121        assert!(is_exempt(Path::new("__init__.py")));
122        assert!(is_exempt(Path::new("pkg/__init__.py")));
123        assert!(!is_exempt(Path::new("conftest.py")));
124        assert!(!is_exempt(Path::new("widget.py")));
125    }
126
127    #[test]
128    fn expected_test_path_is_the_colocated_twin() {
129        assert_eq!(
130            expected_test_path(Path::new("pkg/widget.py")),
131            PathBuf::from("pkg/widget_test.py")
132        );
133        assert_eq!(
134            expected_test_path(Path::new("widget.py")),
135            PathBuf::from("widget_test.py")
136        );
137    }
138}