Skip to main content

testx/
stress.rs

1use std::time::{Duration, Instant};
2
3use crate::adapters::{TestRunResult, TestStatus};
4
5/// Configuration for stress testing mode.
6#[derive(Debug, Clone)]
7pub struct StressConfig {
8    /// Number of times to run the test suite.
9    pub iterations: usize,
10    /// Stop on first failure.
11    pub fail_fast: bool,
12    /// Maximum total duration for all iterations.
13    pub max_duration: Option<Duration>,
14}
15
16impl StressConfig {
17    pub fn new(iterations: usize) -> Self {
18        Self {
19            iterations,
20            fail_fast: false,
21            max_duration: None,
22        }
23    }
24
25    pub fn with_fail_fast(mut self, fail_fast: bool) -> Self {
26        self.fail_fast = fail_fast;
27        self
28    }
29
30    pub fn with_max_duration(mut self, duration: Duration) -> Self {
31        self.max_duration = Some(duration);
32        self
33    }
34}
35
36impl Default for StressConfig {
37    fn default() -> Self {
38        Self::new(10)
39    }
40}
41
42/// Result of a single stress iteration.
43#[derive(Debug, Clone)]
44pub struct IterationResult {
45    pub iteration: usize,
46    pub result: TestRunResult,
47    pub duration: Duration,
48}
49
50/// Aggregated stress test report.
51#[derive(Debug, Clone)]
52pub struct StressReport {
53    pub iterations_completed: usize,
54    pub iterations_requested: usize,
55    pub total_duration: Duration,
56    pub failures: Vec<IterationFailure>,
57    pub flaky_tests: Vec<FlakyTestReport>,
58    pub all_passed: bool,
59    pub stopped_early: bool,
60}
61
62/// A specific failure in a stress test iteration.
63#[derive(Debug, Clone)]
64pub struct IterationFailure {
65    pub iteration: usize,
66    pub failed_tests: Vec<String>,
67}
68
69/// A test that was flaky across stress iterations.
70#[derive(Debug, Clone)]
71pub struct FlakyTestReport {
72    pub name: String,
73    pub suite: String,
74    pub pass_count: usize,
75    pub fail_count: usize,
76    pub total_runs: usize,
77    pub pass_rate: f64,
78    pub durations: Vec<Duration>,
79    pub avg_duration: Duration,
80    pub max_duration: Duration,
81    pub min_duration: Duration,
82}
83
84/// Accumulator that collects iteration results and produces a report.
85pub struct StressAccumulator {
86    config: StressConfig,
87    iterations: Vec<IterationResult>,
88    start_time: Instant,
89}
90
91impl StressAccumulator {
92    pub fn new(config: StressConfig) -> Self {
93        Self {
94            config,
95            iterations: Vec::new(),
96            start_time: Instant::now(),
97        }
98    }
99
100    /// Record one iteration's results. Returns true if we should continue.
101    pub fn record(&mut self, result: TestRunResult, duration: Duration) -> bool {
102        let iteration = self.iterations.len() + 1;
103        let has_failures = result.total_failed() > 0;
104
105        self.iterations.push(IterationResult {
106            iteration,
107            result,
108            duration,
109        });
110
111        if self.config.fail_fast && has_failures {
112            return false;
113        }
114
115        if let Some(max_dur) = self.config.max_duration
116            && self.start_time.elapsed() >= max_dur
117        {
118            return false;
119        }
120
121        iteration < self.config.iterations
122    }
123
124    /// How many iterations have been completed.
125    pub fn completed(&self) -> usize {
126        self.iterations.len()
127    }
128
129    /// Total iterations requested.
130    pub fn requested(&self) -> usize {
131        self.config.iterations
132    }
133
134    /// Check if the max duration has been exceeded.
135    pub fn is_time_exceeded(&self) -> bool {
136        self.config
137            .max_duration
138            .is_some_and(|d| self.start_time.elapsed() >= d)
139    }
140
141    /// Build the final stress report.
142    pub fn report(self) -> StressReport {
143        let iterations_completed = self.iterations.len();
144        let total_duration = self.start_time.elapsed();
145        let stopped_early = iterations_completed < self.config.iterations;
146
147        // Collect failures per iteration
148        let failures: Vec<IterationFailure> = self
149            .iterations
150            .iter()
151            .filter(|it| it.result.total_failed() > 0)
152            .map(|it| {
153                let failed_tests: Vec<String> = it
154                    .result
155                    .suites
156                    .iter()
157                    .flat_map(|s| {
158                        s.tests
159                            .iter()
160                            .filter(|t| t.status == TestStatus::Failed)
161                            .map(move |t| format!("{}::{}", s.name, t.name))
162                    })
163                    .collect();
164
165                IterationFailure {
166                    iteration: it.iteration,
167                    failed_tests,
168                }
169            })
170            .collect();
171
172        // Analyze flaky tests: tests that both passed and failed across iterations
173        let flaky_tests = analyze_flaky_tests(&self.iterations);
174
175        let all_passed = failures.is_empty();
176
177        StressReport {
178            iterations_completed,
179            iterations_requested: self.config.iterations,
180            total_duration,
181            failures,
182            flaky_tests,
183            all_passed,
184            stopped_early,
185        }
186    }
187}
188
189/// Analyze test results across iterations to find flaky tests.
190fn analyze_flaky_tests(iterations: &[IterationResult]) -> Vec<FlakyTestReport> {
191    use std::collections::HashMap;
192
193    // Track per-test status across iterations: (suite, test) -> vec of (status, duration)
194    let mut test_history: HashMap<(String, String), Vec<(TestStatus, Duration)>> = HashMap::new();
195
196    for iteration in iterations {
197        for suite in &iteration.result.suites {
198            for test in &suite.tests {
199                test_history
200                    .entry((suite.name.clone(), test.name.clone()))
201                    .or_default()
202                    .push((test.status.clone(), test.duration));
203            }
204        }
205    }
206
207    let mut flaky_tests: Vec<FlakyTestReport> = test_history
208        .into_iter()
209        .filter_map(|((suite, name), history)| {
210            let pass_count = history
211                .iter()
212                .filter(|(s, _)| *s == TestStatus::Passed)
213                .count();
214            let fail_count = history
215                .iter()
216                .filter(|(s, _)| *s == TestStatus::Failed)
217                .count();
218            let total_runs = history.len();
219
220            // A test is flaky if it both passed and failed
221            if pass_count > 0 && fail_count > 0 {
222                let durations: Vec<Duration> = history.iter().map(|(_, d)| *d).collect();
223                let total_dur: Duration = durations.iter().sum();
224                let avg_duration = total_dur / total_runs as u32;
225                let max_duration = durations.iter().copied().max().unwrap_or_default();
226                let min_duration = durations.iter().copied().min().unwrap_or_default();
227
228                Some(FlakyTestReport {
229                    name,
230                    suite,
231                    pass_count,
232                    fail_count,
233                    total_runs,
234                    pass_rate: pass_count as f64 / total_runs as f64 * 100.0,
235                    durations,
236                    avg_duration,
237                    max_duration,
238                    min_duration,
239                })
240            } else {
241                None
242            }
243        })
244        .collect();
245
246    // Sort by pass rate (lowest = most flaky)
247    flaky_tests.sort_by(|a, b| {
248        a.pass_rate
249            .partial_cmp(&b.pass_rate)
250            .unwrap_or(std::cmp::Ordering::Equal)
251    });
252
253    flaky_tests
254}
255
256/// Format a stress report for display.
257pub fn format_stress_report(report: &StressReport) -> String {
258    let mut lines = Vec::new();
259
260    lines.push(format!(
261        "Stress Test Report: {}/{} iterations in {:.2}s",
262        report.iterations_completed,
263        report.iterations_requested,
264        report.total_duration.as_secs_f64(),
265    ));
266
267    if report.stopped_early {
268        lines.push("  (stopped early)".to_string());
269    }
270
271    lines.push(String::new());
272
273    if report.all_passed {
274        lines.push(format!(
275            "  All {} iterations passed — no flaky tests detected!",
276            report.iterations_completed
277        ));
278    } else {
279        lines.push(format!(
280            "  {} iteration(s) had failures",
281            report.failures.len()
282        ));
283
284        for failure in &report.failures {
285            lines.push(format!("  Iteration {}:", failure.iteration));
286            for test in &failure.failed_tests {
287                lines.push(format!("    - {}", test));
288            }
289        }
290    }
291
292    if !report.flaky_tests.is_empty() {
293        lines.push(String::new());
294        lines.push(format!(
295            "  Flaky tests detected ({}):",
296            report.flaky_tests.len()
297        ));
298        for flaky in &report.flaky_tests {
299            lines.push(format!(
300                "    {} ({}/{} passed, {:.1}% pass rate, avg {:.1}ms)",
301                flaky.name,
302                flaky.pass_count,
303                flaky.total_runs,
304                flaky.pass_rate,
305                flaky.avg_duration.as_secs_f64() * 1000.0,
306            ));
307        }
308    }
309
310    lines.join("\n")
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::adapters::{TestCase, TestError, TestSuite};
317
318    fn make_passing_result(num_tests: usize) -> TestRunResult {
319        TestRunResult {
320            suites: vec![TestSuite {
321                name: "suite".to_string(),
322                tests: (0..num_tests)
323                    .map(|i| TestCase {
324                        name: format!("test_{}", i),
325                        status: TestStatus::Passed,
326                        duration: Duration::from_millis(10),
327                        error: None,
328                    })
329                    .collect(),
330            }],
331            duration: Duration::from_millis(100),
332            raw_exit_code: 0,
333        }
334    }
335
336    fn make_mixed_result(pass: usize, fail: usize) -> TestRunResult {
337        let mut tests: Vec<TestCase> = (0..pass)
338            .map(|i| TestCase {
339                name: format!("pass_{}", i),
340                status: TestStatus::Passed,
341                duration: Duration::from_millis(10),
342                error: None,
343            })
344            .collect();
345
346        for i in 0..fail {
347            tests.push(TestCase {
348                name: format!("fail_{}", i),
349                status: TestStatus::Failed,
350                duration: Duration::from_millis(10),
351                error: Some(TestError {
352                    message: "assertion failed".to_string(),
353                    location: None,
354                }),
355            });
356        }
357
358        TestRunResult {
359            suites: vec![TestSuite {
360                name: "suite".to_string(),
361                tests,
362            }],
363            duration: Duration::from_millis(100),
364            raw_exit_code: 1,
365        }
366    }
367
368    #[test]
369    fn stress_config_defaults() {
370        let cfg = StressConfig::default();
371        assert_eq!(cfg.iterations, 10);
372        assert!(!cfg.fail_fast);
373        assert!(cfg.max_duration.is_none());
374    }
375
376    #[test]
377    fn stress_config_builder() {
378        let cfg = StressConfig::new(100)
379            .with_fail_fast(true)
380            .with_max_duration(Duration::from_secs(60));
381
382        assert_eq!(cfg.iterations, 100);
383        assert!(cfg.fail_fast);
384        assert_eq!(cfg.max_duration, Some(Duration::from_secs(60)));
385    }
386
387    #[test]
388    fn accumulator_all_passing() {
389        let cfg = StressConfig::new(3);
390        let mut acc = StressAccumulator::new(cfg);
391
392        assert!(acc.record(make_passing_result(5), Duration::from_millis(100)));
393        assert!(acc.record(make_passing_result(5), Duration::from_millis(100)));
394        assert!(!acc.record(make_passing_result(5), Duration::from_millis(100)));
395
396        let report = acc.report();
397        assert!(report.all_passed);
398        assert_eq!(report.iterations_completed, 3);
399        assert_eq!(report.iterations_requested, 3);
400        assert!(report.failures.is_empty());
401        assert!(report.flaky_tests.is_empty());
402        assert!(!report.stopped_early);
403    }
404
405    #[test]
406    fn accumulator_fail_fast() {
407        let cfg = StressConfig::new(10).with_fail_fast(true);
408        let mut acc = StressAccumulator::new(cfg);
409
410        assert!(acc.record(make_passing_result(5), Duration::from_millis(100)));
411        // Second iteration fails — should stop
412        assert!(!acc.record(make_mixed_result(3, 2), Duration::from_millis(100)));
413
414        let report = acc.report();
415        assert!(!report.all_passed);
416        assert_eq!(report.iterations_completed, 2);
417        assert!(report.stopped_early);
418        assert_eq!(report.failures.len(), 1);
419        assert_eq!(report.failures[0].iteration, 2);
420    }
421
422    #[test]
423    fn accumulator_without_fail_fast() {
424        let cfg = StressConfig::new(3);
425        let mut acc = StressAccumulator::new(cfg);
426
427        assert!(acc.record(make_passing_result(5), Duration::from_millis(100)));
428        assert!(acc.record(make_mixed_result(3, 2), Duration::from_millis(100)));
429        assert!(!acc.record(make_passing_result(5), Duration::from_millis(100)));
430
431        let report = acc.report();
432        assert!(!report.all_passed);
433        assert_eq!(report.iterations_completed, 3);
434        assert!(!report.stopped_early);
435        assert_eq!(report.failures.len(), 1);
436    }
437
438    #[test]
439    fn flaky_test_detection() {
440        let cfg = StressConfig::new(3);
441        let mut acc = StressAccumulator::new(cfg);
442
443        // Iteration 1: test_0 passes
444        acc.record(make_passing_result(3), Duration::from_millis(100));
445
446        // Iteration 2: test_0 fails (make it flaky)
447        let mut r2 = make_passing_result(3);
448        r2.suites[0].tests[0].status = TestStatus::Failed;
449        r2.suites[0].tests[0].error = Some(TestError {
450            message: "flaky!".to_string(),
451            location: None,
452        });
453        r2.raw_exit_code = 1;
454        acc.record(r2, Duration::from_millis(100));
455
456        // Iteration 3: test_0 passes again
457        acc.record(make_passing_result(3), Duration::from_millis(100));
458
459        let report = acc.report();
460        assert_eq!(report.flaky_tests.len(), 1);
461        assert_eq!(report.flaky_tests[0].name, "test_0");
462        assert_eq!(report.flaky_tests[0].pass_count, 2);
463        assert_eq!(report.flaky_tests[0].fail_count, 1);
464        assert_eq!(report.flaky_tests[0].total_runs, 3);
465    }
466
467    #[test]
468    fn consistently_failing_not_flaky() {
469        let cfg = StressConfig::new(3);
470        let mut acc = StressAccumulator::new(cfg);
471
472        // All iterations have the same failure
473        acc.record(make_mixed_result(3, 1), Duration::from_millis(100));
474        acc.record(make_mixed_result(3, 1), Duration::from_millis(100));
475        acc.record(make_mixed_result(3, 1), Duration::from_millis(100));
476
477        let report = acc.report();
478        // fail_0 always fails — not flaky, just broken
479        assert!(report.flaky_tests.is_empty());
480    }
481
482    #[test]
483    fn consistently_passing_not_flaky() {
484        let cfg = StressConfig::new(5);
485        let mut acc = StressAccumulator::new(cfg);
486
487        for _ in 0..5 {
488            acc.record(make_passing_result(3), Duration::from_millis(100));
489        }
490
491        let report = acc.report();
492        assert!(report.flaky_tests.is_empty());
493    }
494
495    #[test]
496    fn format_report_all_passing() {
497        let report = StressReport {
498            iterations_completed: 10,
499            iterations_requested: 10,
500            total_duration: Duration::from_secs(5),
501            failures: vec![],
502            flaky_tests: vec![],
503            all_passed: true,
504            stopped_early: false,
505        };
506
507        let output = format_stress_report(&report);
508        assert!(output.contains("10/10 iterations"));
509        assert!(output.contains("no flaky tests"));
510    }
511
512    #[test]
513    fn format_report_with_failures() {
514        let report = StressReport {
515            iterations_completed: 5,
516            iterations_requested: 10,
517            total_duration: Duration::from_secs(3),
518            failures: vec![IterationFailure {
519                iteration: 3,
520                failed_tests: vec!["suite::test_1".to_string()],
521            }],
522            flaky_tests: vec![FlakyTestReport {
523                name: "test_1".to_string(),
524                suite: "suite".to_string(),
525                pass_count: 4,
526                fail_count: 1,
527                total_runs: 5,
528                pass_rate: 80.0,
529                durations: vec![Duration::from_millis(10); 5],
530                avg_duration: Duration::from_millis(10),
531                max_duration: Duration::from_millis(15),
532                min_duration: Duration::from_millis(8),
533            }],
534            all_passed: false,
535            stopped_early: true,
536        };
537
538        let output = format_stress_report(&report);
539        assert!(output.contains("stopped early"));
540        assert!(output.contains("Iteration 3"));
541        assert!(output.contains("Flaky tests detected"));
542        assert!(output.contains("80.0% pass rate"));
543    }
544
545    #[test]
546    fn accumulator_completed_count() {
547        let cfg = StressConfig::new(5);
548        let mut acc = StressAccumulator::new(cfg);
549
550        assert_eq!(acc.completed(), 0);
551        assert_eq!(acc.requested(), 5);
552
553        acc.record(make_passing_result(3), Duration::from_millis(100));
554        assert_eq!(acc.completed(), 1);
555
556        acc.record(make_passing_result(3), Duration::from_millis(100));
557        assert_eq!(acc.completed(), 2);
558    }
559
560    #[test]
561    fn flaky_test_duration_stats() {
562        let cfg = StressConfig::new(3);
563        let mut acc = StressAccumulator::new(cfg);
564
565        // Three iterations with varying duration
566        let mut r1 = make_passing_result(1);
567        r1.suites[0].tests[0].duration = Duration::from_millis(10);
568        acc.record(r1, Duration::from_millis(100));
569
570        let mut r2 = make_passing_result(1);
571        r2.suites[0].tests[0].status = TestStatus::Failed;
572        r2.suites[0].tests[0].error = Some(TestError {
573            message: "fail".to_string(),
574            location: None,
575        });
576        r2.suites[0].tests[0].duration = Duration::from_millis(20);
577        r2.raw_exit_code = 1;
578        acc.record(r2, Duration::from_millis(100));
579
580        let mut r3 = make_passing_result(1);
581        r3.suites[0].tests[0].duration = Duration::from_millis(30);
582        acc.record(r3, Duration::from_millis(100));
583
584        let report = acc.report();
585        assert_eq!(report.flaky_tests.len(), 1);
586        let flaky = &report.flaky_tests[0];
587        assert_eq!(flaky.min_duration, Duration::from_millis(10));
588        assert_eq!(flaky.max_duration, Duration::from_millis(30));
589        assert_eq!(flaky.avg_duration, Duration::from_millis(20));
590    }
591
592    #[test]
593    fn multiple_flaky_tests_sorted_by_pass_rate() {
594        let cfg = StressConfig::new(4);
595        let mut acc = StressAccumulator::new(cfg);
596
597        // Create results where test_a fails 3/4 times (25% pass rate)
598        // and test_b fails 1/4 times (75% pass rate)
599        for i in 0..4 {
600            let result = TestRunResult {
601                suites: vec![TestSuite {
602                    name: "suite".to_string(),
603                    tests: vec![
604                        TestCase {
605                            name: "test_a".to_string(),
606                            status: if i == 0 {
607                                TestStatus::Passed
608                            } else {
609                                TestStatus::Failed
610                            },
611                            duration: Duration::from_millis(10),
612                            error: if i == 0 {
613                                None
614                            } else {
615                                Some(TestError {
616                                    message: "fail".into(),
617                                    location: None,
618                                })
619                            },
620                        },
621                        TestCase {
622                            name: "test_b".to_string(),
623                            status: if i == 2 {
624                                TestStatus::Failed
625                            } else {
626                                TestStatus::Passed
627                            },
628                            duration: Duration::from_millis(10),
629                            error: if i == 2 {
630                                Some(TestError {
631                                    message: "fail".into(),
632                                    location: None,
633                                })
634                            } else {
635                                None
636                            },
637                        },
638                    ],
639                }],
640                duration: Duration::from_millis(100),
641                raw_exit_code: if i == 0 { 0 } else { 1 },
642            };
643            acc.record(result, Duration::from_millis(100));
644        }
645
646        let report = acc.report();
647        assert_eq!(report.flaky_tests.len(), 2);
648
649        // Should be sorted by pass rate (lowest first)
650        assert_eq!(report.flaky_tests[0].name, "test_a");
651        assert_eq!(report.flaky_tests[1].name, "test_b");
652        assert!(report.flaky_tests[0].pass_rate < report.flaky_tests[1].pass_rate);
653    }
654}