1use std::time::{Duration, Instant};
2
3use crate::adapters::{TestRunResult, TestStatus};
4
5#[derive(Debug, Clone)]
7pub struct StressConfig {
8 pub iterations: usize,
10 pub fail_fast: bool,
12 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#[derive(Debug, Clone)]
44pub struct IterationResult {
45 pub iteration: usize,
46 pub result: TestRunResult,
47 pub duration: Duration,
48}
49
50#[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#[derive(Debug, Clone)]
64pub struct IterationFailure {
65 pub iteration: usize,
66 pub failed_tests: Vec<String>,
67}
68
69#[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
84pub 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 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 pub fn completed(&self) -> usize {
126 self.iterations.len()
127 }
128
129 pub fn requested(&self) -> usize {
131 self.config.iterations
132 }
133
134 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 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 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 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
189fn analyze_flaky_tests(iterations: &[IterationResult]) -> Vec<FlakyTestReport> {
191 use std::collections::HashMap;
192
193 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 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 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
256pub 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 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 acc.record(make_passing_result(3), Duration::from_millis(100));
445
446 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 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 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 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 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 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 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}