1use std::sync::Arc;
6use std::time::{Duration, Instant};
7use thiserror::Error;
8
9#[derive(Debug, Error)]
11pub enum LoadTestError {
12 #[error("Test failed: {0}")]
13 TestFailed(String),
14
15 #[error("Timeout: {0}")]
16 Timeout(String),
17}
18
19#[derive(Debug, Clone)]
21pub struct LoadTestStats {
22 pub total_requests: u64,
24
25 pub successful: u64,
27
28 pub failed: u64,
30
31 pub duration: Duration,
33
34 pub min_response_time: Duration,
36
37 pub max_response_time: Duration,
39
40 pub avg_response_time: Duration,
42
43 pub median_response_time: Duration,
45
46 pub p95_response_time: Duration,
48
49 pub p99_response_time: Duration,
51
52 pub rps: f64,
54}
55
56impl LoadTestStats {
57 pub fn from_response_times(
59 response_times: &[Duration],
60 failed: u64,
61 total_duration: Duration,
62 ) -> Self {
63 let mut sorted = response_times.to_vec();
64 sorted.sort();
65
66 let total = response_times.len() as u64;
67 let sum: Duration = response_times.iter().sum();
68
69 let min = sorted.first().copied().unwrap_or_default();
70 let max = sorted.last().copied().unwrap_or_default();
71 let avg = if !sorted.is_empty() {
72 sum / sorted.len() as u32
73 } else {
74 Duration::default()
75 };
76
77 let median = Self::quantile(&sorted, 0.50);
86 let p95 = Self::quantile(&sorted, 0.95);
87 let p99 = Self::quantile(&sorted, 0.99);
88
89 let rps = if total_duration.as_secs_f64() > 0.0 {
90 total as f64 / total_duration.as_secs_f64()
91 } else {
92 0.0
93 };
94
95 Self {
96 total_requests: total + failed,
97 successful: total,
98 failed,
99 duration: total_duration,
100 min_response_time: min,
101 max_response_time: max,
102 avg_response_time: avg,
103 median_response_time: median,
104 p95_response_time: p95,
105 p99_response_time: p99,
106 rps,
107 }
108 }
109
110 fn quantile(sorted: &[Duration], q: f64) -> Duration {
115 if sorted.is_empty() {
116 return Duration::default();
117 }
118 let rank = (q * sorted.len() as f64).ceil() as usize;
119 let idx = rank.saturating_sub(1).min(sorted.len() - 1);
120 sorted[idx]
121 }
122
123 pub fn print(&self) {
125 println!("\n========== Load Test Results ==========");
126 println!("Total Requests: {}", self.total_requests);
127 println!("Successful: {}", self.successful);
128 println!("Failed: {}", self.failed);
129 println!("Duration: {:.2}s", self.duration.as_secs_f64());
130 println!("Requests/sec: {:.2}", self.rps);
131 println!("\nResponse Times:");
132 println!(
133 " Min: {:.2}ms",
134 self.min_response_time.as_millis()
135 );
136 println!(
137 " Avg: {:.2}ms",
138 self.avg_response_time.as_millis()
139 );
140 println!(
141 " Median (p50): {:.2}ms",
142 self.median_response_time.as_millis()
143 );
144 println!(
145 " p95: {:.2}ms",
146 self.p95_response_time.as_millis()
147 );
148 println!(
149 " p99: {:.2}ms",
150 self.p99_response_time.as_millis()
151 );
152 println!(
153 " Max: {:.2}ms",
154 self.max_response_time.as_millis()
155 );
156 println!("=======================================\n");
157 }
158}
159
160#[derive(Debug, Clone)]
162pub struct LoadTestConfig {
163 pub concurrency: usize,
165
166 pub total_requests: u64,
168
169 pub duration: Option<Duration>,
171
172 pub rate_limit: Option<f64>,
174
175 pub timeout: Duration,
177}
178
179impl LoadTestConfig {
180 pub fn new(concurrency: usize, total_requests: u64) -> Self {
190 Self {
191 concurrency,
192 total_requests,
193 duration: None,
194 rate_limit: None,
195 timeout: Duration::from_secs(30),
196 }
197 }
198
199 pub fn with_duration(mut self, duration: Duration) -> Self {
201 self.duration = Some(duration);
202 self
203 }
204
205 pub fn with_rate_limit(mut self, rps: f64) -> Self {
207 self.rate_limit = Some(rps);
208 self
209 }
210
211 pub fn with_timeout(mut self, timeout: Duration) -> Self {
213 self.timeout = timeout;
214 self
215 }
216}
217
218impl Default for LoadTestConfig {
219 fn default() -> Self {
220 Self::new(10, 100)
221 }
222}
223
224pub struct LoadTestRunner<F, Fut>
226where
227 F: Fn() -> Fut + Send + Sync + Clone + 'static,
228 Fut: std::future::Future<Output = Result<(), LoadTestError>> + Send + 'static,
229{
230 config: LoadTestConfig,
231 test_fn: Arc<F>,
232}
233
234impl<F, Fut> LoadTestRunner<F, Fut>
235where
236 F: Fn() -> Fut + Send + Sync + Clone + 'static,
237 Fut: std::future::Future<Output = Result<(), LoadTestError>> + Send + 'static,
238{
239 pub fn new(config: LoadTestConfig, test_fn: F) -> Self {
253 Self {
254 config,
255 test_fn: Arc::new(test_fn),
256 }
257 }
258
259 pub async fn run(&self) -> Result<LoadTestStats, LoadTestError> {
261 let start_time = Instant::now();
262
263 let mut handles: Vec<tokio::task::JoinHandle<(Vec<Duration>, u64)>> = vec![];
271
272 let concurrency = self.config.concurrency as u64;
273
274 let min_interval = self
279 .config
280 .rate_limit
281 .filter(|rps| *rps > 0.0)
282 .map(|rps| Duration::from_secs_f64(concurrency.max(1) as f64 / rps));
283
284 for worker_idx in 0..self.config.concurrency {
285 let test_fn = self.test_fn.clone();
286 let timeout = self.config.timeout;
287 let duration = self.config.duration;
288
289 let requests_for_this_worker = if duration.is_some() {
297 None
298 } else {
299 let base = self.config.total_requests / concurrency.max(1);
300 let remainder = self.config.total_requests % concurrency.max(1);
301 Some(base + u64::from((worker_idx as u64) < remainder))
302 };
303
304 let handle = tokio::spawn(async move {
305 let worker_start = Instant::now();
306 let mut request_count = 0u64;
307 let mut worker_times: Vec<Duration> = Vec::new();
308 let mut worker_failed = 0u64;
309
310 let should_stop = |request_count: u64| -> bool {
316 if let Some(duration) = duration {
317 worker_start.elapsed() >= duration
318 } else if let Some(max_requests) = requests_for_this_worker {
319 request_count >= max_requests
320 } else {
321 false
322 }
323 };
324
325 loop {
326 if should_stop(request_count) {
328 break;
329 }
330
331 let req_start = Instant::now();
333 let result = tokio::time::timeout(timeout, test_fn()).await;
334
335 match result {
336 Ok(Ok(())) => {
337 let elapsed = req_start.elapsed();
338 worker_times.push(elapsed);
339 }
340 _ => {
341 worker_failed += 1;
342 }
343 }
344
345 request_count += 1;
346
347 if !should_stop(request_count)
355 && let Some(interval) = min_interval
356 {
357 let elapsed = req_start.elapsed();
358 if elapsed < interval {
359 tokio::time::sleep(interval - elapsed).await;
360 }
361 }
362 }
363
364 (worker_times, worker_failed)
365 });
366
367 handles.push(handle);
368 }
369
370 let mut response_times: Vec<Duration> = Vec::new();
372 let mut failed = 0u64;
373 for handle in handles {
374 if let Ok((times, worker_failed)) = handle.await {
375 response_times.extend(times);
376 failed += worker_failed;
377 }
378 }
379
380 let total_duration = start_time.elapsed();
381
382 Ok(LoadTestStats::from_response_times(
383 &response_times,
384 failed,
385 total_duration,
386 ))
387 }
388}
389
390pub struct StressTestRunner<F, Fut>
392where
393 F: Fn() -> Fut + Send + Sync + Clone + 'static,
394 Fut: std::future::Future<Output = Result<(), LoadTestError>> + Send + 'static,
395{
396 initial_concurrency: usize,
397 max_concurrency: usize,
398 step_size: usize,
399 step_duration: Duration,
400 test_fn: Arc<F>,
401}
402
403impl<F, Fut> StressTestRunner<F, Fut>
404where
405 F: Fn() -> Fut + Send + Sync + Clone + 'static,
406 Fut: std::future::Future<Output = Result<(), LoadTestError>> + Send + 'static,
407{
408 pub fn new(
422 initial_concurrency: usize,
423 max_concurrency: usize,
424 step_size: usize,
425 step_duration: Duration,
426 test_fn: F,
427 ) -> Self {
428 Self {
429 initial_concurrency,
430 max_concurrency,
431 step_size,
432 step_duration,
433 test_fn: Arc::new(test_fn),
434 }
435 }
436
437 pub async fn run(&self) -> Result<Vec<(usize, LoadTestStats)>, LoadTestError> {
439 let mut results = vec![];
440 let mut concurrency = self.initial_concurrency;
441
442 println!("\n========== Stress Test Starting ==========");
443 println!("Initial Concurrency: {}", self.initial_concurrency);
444 println!("Max Concurrency: {}", self.max_concurrency);
445 println!("Step Size: {}", self.step_size);
446 println!(
447 "Step Duration: {:.0}s",
448 self.step_duration.as_secs_f64()
449 );
450 println!("==========================================\n");
451
452 while concurrency <= self.max_concurrency {
453 println!("Testing with {} concurrent requests...", concurrency);
454
455 let config =
456 LoadTestConfig::new(concurrency, u64::MAX).with_duration(self.step_duration);
457
458 let runner = LoadTestRunner::new(config, self.test_fn.as_ref().clone());
459 let stats = runner.run().await?;
460
461 println!(
462 " RPS: {:.2}, Avg: {:.2}ms, p95: {:.2}ms",
463 stats.rps,
464 stats.avg_response_time.as_millis(),
465 stats.p95_response_time.as_millis()
466 );
467
468 results.push((concurrency, stats));
469 concurrency += self.step_size;
470 }
471
472 println!("\n========== Stress Test Complete ==========\n");
473
474 Ok(results)
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn test_load_test_config() {
484 let config = LoadTestConfig::new(10, 100)
485 .with_rate_limit(50.0)
486 .with_timeout(Duration::from_secs(5));
487
488 assert_eq!(config.concurrency, 10);
489 assert_eq!(config.total_requests, 100);
490 assert_eq!(config.rate_limit, Some(50.0));
491 }
492
493 #[test]
494 fn test_load_test_stats() {
495 let response_times = vec![
496 Duration::from_millis(100),
497 Duration::from_millis(200),
498 Duration::from_millis(150),
499 Duration::from_millis(300),
500 Duration::from_millis(250),
501 ];
502
503 let stats = LoadTestStats::from_response_times(&response_times, 0, Duration::from_secs(1));
504
505 assert_eq!(stats.total_requests, 5);
506 assert_eq!(stats.successful, 5);
507 assert_eq!(stats.failed, 0);
508 assert_eq!(stats.min_response_time, Duration::from_millis(100));
509 assert_eq!(stats.max_response_time, Duration::from_millis(300));
510 }
511
512 #[tokio::test]
513 async fn test_load_test_runner() {
514 let config = LoadTestConfig::new(2, 10);
515
516 let runner = LoadTestRunner::new(config, || async {
517 tokio::time::sleep(Duration::from_millis(10)).await;
518 Ok(())
519 });
520
521 let stats = runner.run().await.unwrap();
522 assert_eq!(stats.total_requests, 10);
523 assert_eq!(stats.successful, 10);
524 }
525
526 #[tokio::test]
527 async fn run_executes_exactly_total_requests_when_not_evenly_divisible() {
528 use std::sync::atomic::{AtomicU64, Ordering};
532 static COUNTER: AtomicU64 = AtomicU64::new(0);
533 COUNTER.store(0, Ordering::SeqCst);
534
535 let config = LoadTestConfig::new(3, 100);
536 let runner = LoadTestRunner::new(config, || async {
537 COUNTER.fetch_add(1, Ordering::SeqCst);
538 Ok(())
539 });
540
541 let stats = runner.run().await.unwrap();
542 assert_eq!(COUNTER.load(Ordering::SeqCst), 100);
543 assert_eq!(stats.total_requests, 100);
544 }
545
546 #[tokio::test]
547 async fn run_executes_exactly_total_requests_when_concurrency_exceeds_total() {
548 use std::sync::atomic::{AtomicU64, Ordering};
551 static COUNTER: AtomicU64 = AtomicU64::new(0);
552 COUNTER.store(0, Ordering::SeqCst);
553
554 let config = LoadTestConfig::new(5, 2);
555 let runner = LoadTestRunner::new(config, || async {
556 COUNTER.fetch_add(1, Ordering::SeqCst);
557 Ok(())
558 });
559
560 let stats = runner.run().await.unwrap();
561 assert_eq!(COUNTER.load(Ordering::SeqCst), 2);
562 assert_eq!(stats.total_requests, 2);
563 }
564
565 #[tokio::test]
566 async fn run_honors_rate_limit_by_pacing_requests() {
567 let config = LoadTestConfig::new(1, 4).with_rate_limit(20.0);
579 let runner = LoadTestRunner::new(config, || async { Ok(()) });
580
581 let start = Instant::now();
582 let stats = runner.run().await.unwrap();
583 let elapsed = start.elapsed();
584
585 assert_eq!(stats.total_requests, 4);
586 assert!(
587 elapsed >= Duration::from_millis(140) && elapsed < Duration::from_millis(195),
588 "expected pacing to take ~150ms for 3 gaps at 50ms (not ~200ms for 4 sleeps), took {elapsed:?}"
589 );
590 }
591}