Skip to main content

rta_core/analyzers/
testing.rs

1use crate::{analyzers::Analyzer, collector::RepositorySnapshot, model::TestingMaturity};
2
3pub struct TestingAnalyzer;
4
5impl Analyzer<TestingMaturity> for TestingAnalyzer {
6    fn analyze(&self, snapshot: &RepositorySnapshot) -> TestingMaturity {
7        let mut unit_test_files = 0;
8        let mut integration_test_files = 0;
9        let mut test_function_count = 0;
10
11        for file in snapshot.rust_files() {
12            let content = file.content.as_deref().unwrap_or_default();
13            let is_integration_test =
14                file.relative_path.starts_with("tests/") || file.relative_path.contains("/tests/");
15            let has_unit_tests = content.contains("#[cfg(test)]") || has_test_attribute(content);
16
17            if is_integration_test {
18                integration_test_files += 1;
19                test_function_count += count_test_functions(content);
20            } else if has_unit_tests {
21                unit_test_files += 1;
22                test_function_count += count_test_functions(content);
23            }
24        }
25
26        let has_tests = unit_test_files > 0 || integration_test_files > 0;
27        let testing_structure = if !has_tests {
28            "No Rust test structure detected.".to_string()
29        } else if integration_test_files > 0 && unit_test_files > 0 {
30            "Unit and integration testing structures detected.".to_string()
31        } else if integration_test_files > 0 {
32            "Integration tests detected; unit test coverage should be reviewed.".to_string()
33        } else {
34            "Unit tests detected; integration test coverage should be reviewed.".to_string()
35        };
36
37        let mut score = if has_tests { 55_i32 } else { 20_i32 };
38        score += (unit_test_files as i32 * 6).min(20);
39        score += (integration_test_files as i32 * 10).min(20);
40        score += (test_function_count as i32).min(15);
41
42        TestingMaturity {
43            has_tests,
44            unit_test_files,
45            integration_test_files,
46            test_function_count,
47            testing_structure,
48            score: score.clamp(0, 100) as u8,
49        }
50    }
51}
52
53fn count_test_functions(content: &str) -> usize {
54    content
55        .lines()
56        .map(str::trim)
57        .filter(|line| is_test_attribute(line))
58        .count()
59}
60
61fn has_test_attribute(content: &str) -> bool {
62    content.lines().map(str::trim).any(is_test_attribute)
63}
64
65fn is_test_attribute(line: &str) -> bool {
66    line == "#[test]" || line.starts_with("#[tokio::test")
67}
68
69#[cfg(test)]
70mod tests {
71    use std::path::PathBuf;
72
73    use crate::{
74        analyzers::Analyzer,
75        collector::{CargoManifest, FileSnapshot, RepositorySnapshot},
76    };
77
78    use super::TestingAnalyzer;
79
80    #[test]
81    fn empty_repository_has_low_testing_maturity() {
82        let report = TestingAnalyzer.analyze(&snapshot(Vec::new()));
83
84        assert!(!report.has_tests);
85        assert_eq!(report.score, 20);
86    }
87
88    #[test]
89    fn typical_repository_detects_unit_and_integration_tests() {
90        let report = TestingAnalyzer.analyze(&snapshot(vec![
91            file(
92                "src/lib.rs",
93                "#[cfg(test)]\nmod tests {\n#[test]\nfn it_works() {}\n}\n",
94            ),
95            file("tests/smoke.rs", "#[test]\nfn smoke() {}\n"),
96        ]));
97
98        assert!(report.has_tests);
99        assert_eq!(report.unit_test_files, 1);
100        assert_eq!(report.integration_test_files, 1);
101        assert_eq!(report.test_function_count, 2);
102    }
103
104    #[test]
105    fn extreme_test_count_caps_score_at_hundred() {
106        let mut content = String::new();
107        for index in 0..80 {
108            content.push_str(&format!("#[test]\nfn test_{index}() {{}}\n"));
109        }
110        let report = TestingAnalyzer.analyze(&snapshot(vec![
111            file(
112                "src/lib.rs",
113                "#[cfg(test)]\nmod tests {\n#[test]\nfn unit() {}\n}\n",
114            ),
115            file(
116                "src/core.rs",
117                "#[cfg(test)]\nmod tests {\n#[test]\nfn unit() {}\n}\n",
118            ),
119            file(
120                "src/api.rs",
121                "#[cfg(test)]\nmod tests {\n#[test]\nfn unit() {}\n}\n",
122            ),
123            file(
124                "src/db.rs",
125                "#[cfg(test)]\nmod tests {\n#[test]\nfn unit() {}\n}\n",
126            ),
127            file("tests/many.rs", &content),
128            file("tests/smoke.rs", "#[test]\nfn smoke() {}\n"),
129        ]));
130
131        assert_eq!(report.test_function_count, 85);
132        assert_eq!(report.score, 100);
133    }
134
135    #[test]
136    fn adversarial_commented_test_attribute_is_ignored() {
137        let report = TestingAnalyzer.analyze(&snapshot(vec![file(
138            "src/lib.rs",
139            "// #[test]\n// fn fake() {}\npub fn real() {}\n",
140        )]));
141
142        assert!(!report.has_tests);
143        assert_eq!(report.test_function_count, 0);
144    }
145
146    fn snapshot(files: Vec<FileSnapshot>) -> RepositorySnapshot {
147        RepositorySnapshot {
148            root: PathBuf::from("/tmp/repo"),
149            files,
150            manifests: Vec::<CargoManifest>::new(),
151        }
152    }
153
154    fn file(relative_path: &str, content: &str) -> FileSnapshot {
155        FileSnapshot {
156            path: PathBuf::from("/tmp/repo").join(relative_path),
157            relative_path: relative_path.into(),
158            extension: Some("rs".into()),
159            bytes: content.len() as u64,
160            lines: content.lines().count(),
161            content: Some(content.into()),
162        }
163    }
164}