Skip to main content

dev_flaky/
lib.rs

1//! # dev-flaky
2//!
3//! Flaky-test detection for Rust. Part of the `dev-*` verification suite.
4//!
5//! Runs the test suite N times and tracks which tests pass every run vs
6//! which fail sometimes. Emits per-test reliability as
7//! `dev-report::Report`.
8//!
9//! ## Why
10//!
11//! Flaky tests rot trust in your test suite. After enough false alarms,
12//! developers start ignoring failures, and then real failures get missed.
13//! `dev-flaky` detects flakiness automatically so you can quarantine or
14//! fix the worst offenders.
15//!
16//! ## Quick example
17//!
18//! ```no_run
19//! use dev_flaky::FlakyRun;
20//!
21//! let run = FlakyRun::new("my-crate", "0.1.0").iterations(20);
22//! let result = run.execute().unwrap();
23//! let report = result.into_report();
24//! ```
25//!
26//! ## Status
27//!
28//! Pre-1.0. API shape defined; subprocess integration lands in `0.9.1`.
29
30#![cfg_attr(docsrs, feature(doc_cfg))]
31#![warn(missing_docs)]
32#![warn(rust_2018_idioms)]
33
34use dev_report::{CheckResult, Evidence, Report, Severity};
35
36/// Configuration for a flaky-test detection run.
37#[derive(Debug, Clone)]
38pub struct FlakyRun {
39    name: String,
40    version: String,
41    iterations: u32,
42}
43
44impl FlakyRun {
45    /// Begin a new flaky-test run. Defaults to 10 iterations.
46    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
47        Self {
48            name: name.into(),
49            version: version.into(),
50            iterations: 10,
51        }
52    }
53
54    /// Set how many iterations to run.
55    pub fn iterations(mut self, n: u32) -> Self {
56        self.iterations = n.max(2);
57        self
58    }
59
60    /// Configured iteration count.
61    pub fn iteration_count(&self) -> u32 {
62        self.iterations
63    }
64
65    /// Execute the run.
66    ///
67    /// In `0.9.0` this is a stub; subprocess integration lands in `0.9.1`.
68    pub fn execute(&self) -> Result<FlakyResult, FlakyError> {
69        Ok(FlakyResult {
70            name: self.name.clone(),
71            version: self.version.clone(),
72            iterations: self.iterations,
73            tests: Vec::new(),
74        })
75    }
76}
77
78/// Per-test reliability record.
79#[derive(Debug, Clone)]
80pub struct TestReliability {
81    /// Full test path (e.g. `crate::module::test_name`).
82    pub name: String,
83    /// Number of times this test passed.
84    pub passes: u32,
85    /// Number of times this test failed.
86    pub failures: u32,
87}
88
89impl TestReliability {
90    /// Fraction of runs that passed, in the range `[0.0, 1.0]`.
91    pub fn reliability(&self) -> f64 {
92        let total = self.passes + self.failures;
93        if total == 0 {
94            return 0.0;
95        }
96        self.passes as f64 / total as f64
97    }
98
99    /// `true` if this test is fully reliable (no failures).
100    pub fn is_stable(&self) -> bool {
101        self.failures == 0
102    }
103
104    /// `true` if this test is fully broken (no passes).
105    pub fn is_broken(&self) -> bool {
106        self.passes == 0
107    }
108
109    /// `true` if this test is flaky (mixed pass/fail history).
110    pub fn is_flaky(&self) -> bool {
111        self.passes > 0 && self.failures > 0
112    }
113}
114
115/// Result of a flaky-test run.
116#[derive(Debug, Clone)]
117pub struct FlakyResult {
118    /// Crate name.
119    pub name: String,
120    /// Crate version.
121    pub version: String,
122    /// Iterations completed.
123    pub iterations: u32,
124    /// Per-test reliability records.
125    pub tests: Vec<TestReliability>,
126}
127
128impl FlakyResult {
129    /// Count of tests that are flaky (mixed pass/fail).
130    pub fn flaky_count(&self) -> usize {
131        self.tests.iter().filter(|t| t.is_flaky()).count()
132    }
133
134    /// Convert this result into a `dev-report::Report`.
135    ///
136    /// Stable tests pass. Flaky tests warn (reliability is reported
137    /// as evidence). Broken tests (zero passes) fail.
138    pub fn into_report(self) -> Report {
139        let mut report = Report::new(&self.name, &self.version).with_producer("dev-flaky");
140        if self.tests.is_empty() {
141            report.push(CheckResult::pass("flaky::scan"));
142        } else {
143            for t in &self.tests {
144                let name = format!("flaky::{}", t.name);
145                let reliability_pct = t.reliability() * 100.0;
146                let detail = format!(
147                    "{}/{} passed ({:.1}%)",
148                    t.passes,
149                    t.passes + t.failures,
150                    reliability_pct
151                );
152                let check = if t.is_broken() {
153                    CheckResult::fail(name, Severity::Error).with_detail(detail)
154                } else if t.is_flaky() {
155                    CheckResult::warn(name, Severity::Warning).with_detail(detail)
156                } else {
157                    CheckResult::pass(name).with_detail(detail)
158                };
159                report.push(
160                    check.with_evidence(Evidence::numeric("reliability_pct", reliability_pct)),
161                );
162            }
163        }
164        report.finish();
165        report
166    }
167}
168
169/// Errors that can arise during a flaky-test run.
170#[derive(Debug)]
171pub enum FlakyError {
172    /// `cargo test` subprocess failed.
173    SubprocessFailed(String),
174    /// Output parsing failure.
175    ParseError(String),
176}
177
178impl std::fmt::Display for FlakyError {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        match self {
181            Self::SubprocessFailed(s) => write!(f, "subprocess failed: {s}"),
182            Self::ParseError(s) => write!(f, "parse error: {s}"),
183        }
184    }
185}
186
187impl std::error::Error for FlakyError {}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn iterations_min_two() {
195        let r = FlakyRun::new("x", "0.1.0").iterations(1);
196        assert_eq!(r.iteration_count(), 2);
197    }
198
199    #[test]
200    fn stable_test_classified_correctly() {
201        let t = TestReliability {
202            name: "a".into(),
203            passes: 10,
204            failures: 0,
205        };
206        assert!(t.is_stable());
207        assert!(!t.is_flaky());
208        assert!(!t.is_broken());
209        assert_eq!(t.reliability(), 1.0);
210    }
211
212    #[test]
213    fn broken_test_classified_correctly() {
214        let t = TestReliability {
215            name: "b".into(),
216            passes: 0,
217            failures: 10,
218        };
219        assert!(t.is_broken());
220        assert!(!t.is_flaky());
221        assert_eq!(t.reliability(), 0.0);
222    }
223
224    #[test]
225    fn flaky_test_classified_correctly() {
226        let t = TestReliability {
227            name: "c".into(),
228            passes: 7,
229            failures: 3,
230        };
231        assert!(t.is_flaky());
232        assert!(!t.is_stable());
233        assert!(!t.is_broken());
234        assert!((t.reliability() - 0.7).abs() < 0.0001);
235    }
236
237    #[test]
238    fn empty_result_passes() {
239        let r = FlakyResult {
240            name: "x".into(),
241            version: "0.1.0".into(),
242            iterations: 10,
243            tests: Vec::new(),
244        };
245        let report = r.into_report();
246        assert!(report.passed());
247    }
248
249    #[test]
250    fn broken_test_produces_failing_report() {
251        let r = FlakyResult {
252            name: "x".into(),
253            version: "0.1.0".into(),
254            iterations: 10,
255            tests: vec![TestReliability {
256                name: "broken".into(),
257                passes: 0,
258                failures: 10,
259            }],
260        };
261        assert!(r.into_report().failed());
262    }
263
264    #[test]
265    fn flaky_count_correct() {
266        let r = FlakyResult {
267            name: "x".into(),
268            version: "0.1.0".into(),
269            iterations: 10,
270            tests: vec![
271                TestReliability {
272                    name: "stable".into(),
273                    passes: 10,
274                    failures: 0,
275                },
276                TestReliability {
277                    name: "flaky_a".into(),
278                    passes: 7,
279                    failures: 3,
280                },
281                TestReliability {
282                    name: "flaky_b".into(),
283                    passes: 5,
284                    failures: 5,
285                },
286            ],
287        };
288        assert_eq!(r.flaky_count(), 2);
289    }
290}