1#![allow(clippy::must_use_candidate)]
11#![allow(clippy::missing_panics_doc)]
12#![allow(clippy::missing_errors_doc)]
13#![allow(clippy::module_name_repetitions)]
14#![allow(clippy::missing_const_for_fn)]
15#![allow(clippy::struct_excessive_bools)]
16#![allow(clippy::cast_possible_truncation)]
17#![allow(clippy::cast_precision_loss)]
18#![allow(clippy::cast_lossless)]
19#![allow(clippy::cast_sign_loss)]
20#![allow(clippy::suboptimal_flops)]
21#![allow(clippy::format_push_string)]
22#![allow(clippy::uninlined_format_args)]
23#![allow(clippy::return_self_not_must_use)]
24#![allow(clippy::unwrap_used)]
25
26use serde::{Deserialize, Serialize};
27use std::collections::HashMap;
28use std::path::PathBuf;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct LoadTestConfig {
37 pub target_url: String,
39 pub users: UserConfig,
41 pub duration_secs: u64,
43 pub scenario: Option<PathBuf>,
45 pub output: LoadTestOutputFormat,
47}
48
49impl LoadTestConfig {
50 pub fn new(target_url: &str, users: u32, duration_secs: u64) -> Self {
52 Self {
53 target_url: target_url.to_string(),
54 users: UserConfig::Fixed(users),
55 duration_secs,
56 scenario: None,
57 output: LoadTestOutputFormat::Text,
58 }
59 }
60
61 pub fn with_ramp(
63 target_url: &str,
64 start_users: u32,
65 end_users: u32,
66 ramp_secs: u64,
67 duration_secs: u64,
68 ) -> Self {
69 Self {
70 target_url: target_url.to_string(),
71 users: UserConfig::Ramp {
72 start: start_users,
73 end: end_users,
74 ramp_secs,
75 },
76 duration_secs,
77 scenario: None,
78 output: LoadTestOutputFormat::Text,
79 }
80 }
81
82 pub fn from_scenario(scenario_path: PathBuf) -> Self {
84 Self {
85 target_url: String::new(),
86 users: UserConfig::Fixed(1),
87 duration_secs: 0,
88 scenario: Some(scenario_path),
89 output: LoadTestOutputFormat::Text,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(tag = "type")]
97pub enum UserConfig {
98 Fixed(u32),
100 Ramp {
102 start: u32,
104 end: u32,
106 ramp_secs: u64,
108 },
109}
110
111impl UserConfig {
112 pub fn users_at(&self, elapsed_secs: u64) -> u32 {
114 match self {
115 Self::Fixed(users) => *users,
116 Self::Ramp {
117 start,
118 end,
119 ramp_secs,
120 } => {
121 if elapsed_secs >= *ramp_secs {
122 *end
123 } else {
124 let progress = elapsed_secs as f64 / *ramp_secs as f64;
125 let range = (*end as i64 - *start as i64) as f64;
126 (*start as f64 + range * progress) as u32
127 }
128 }
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
135pub enum LoadTestOutputFormat {
136 #[default]
138 Text,
139 Json,
141 Csv,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct LoadTestScenario {
152 pub name: String,
154 pub description: String,
156 #[serde(default)]
158 pub citation: Option<String>,
159 pub stages: Vec<LoadTestStage>,
161 pub requests: Vec<LoadTestRequest>,
163}
164
165impl LoadTestScenario {
166 pub fn new(name: &str, description: &str) -> Self {
168 Self {
169 name: name.to_string(),
170 description: description.to_string(),
171 citation: None,
172 stages: Vec::new(),
173 requests: Vec::new(),
174 }
175 }
176
177 pub fn add_stage(&mut self, stage: LoadTestStage) {
179 self.stages.push(stage);
180 }
181
182 pub fn add_request(&mut self, request: LoadTestRequest) {
184 self.requests.push(request);
185 }
186
187 pub fn total_duration_secs(&self) -> u64 {
189 self.stages.iter().map(|s| s.duration_secs).sum()
190 }
191
192 pub fn from_yaml(yaml: &str) -> Result<Self, String> {
194 serde_yaml_ng::from_str(yaml).map_err(|e| format!("Failed to parse YAML: {}", e))
195 }
196
197 pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
199 let content = std::fs::read_to_string(path)?;
200 Self::from_yaml(&content)
201 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
202 }
203
204 pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
206 let content = serde_yaml_ng::to_string(self)
207 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
208 std::fs::write(path, content)
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct LoadTestStage {
215 pub name: String,
217 pub duration_secs: u64,
219 pub users_start: u32,
221 pub users_end: u32,
223}
224
225impl LoadTestStage {
226 pub fn steady(name: &str, duration_secs: u64, users: u32) -> Self {
228 Self {
229 name: name.to_string(),
230 duration_secs,
231 users_start: users,
232 users_end: users,
233 }
234 }
235
236 pub fn ramp(name: &str, duration_secs: u64, start_users: u32, end_users: u32) -> Self {
238 Self {
239 name: name.to_string(),
240 duration_secs,
241 users_start: start_users,
242 users_end: end_users,
243 }
244 }
245
246 pub fn is_ramp(&self) -> bool {
248 self.users_start != self.users_end
249 }
250
251 pub fn users_at(&self, offset_secs: u64) -> u32 {
253 if !self.is_ramp() || self.duration_secs == 0 {
254 return self.users_start;
255 }
256 let progress = (offset_secs as f64 / self.duration_secs as f64).min(1.0);
257 let range = (self.users_end as i64 - self.users_start as i64) as f64;
258 (self.users_start as f64 + range * progress) as u32
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct LoadTestRequest {
265 pub name: String,
267 pub method: HttpMethod,
269 pub path: String,
271 #[serde(default = "default_weight")]
273 pub weight: f64,
274 #[serde(default)]
276 pub headers: HashMap<String, String>,
277 #[serde(default)]
279 pub body: Option<String>,
280 #[serde(default)]
282 pub assertions: Vec<LoadTestAssertion>,
283}
284
285fn default_weight() -> f64 {
286 1.0
287}
288
289impl LoadTestRequest {
290 pub fn get(name: &str, path: &str) -> Self {
292 Self {
293 name: name.to_string(),
294 method: HttpMethod::Get,
295 path: path.to_string(),
296 weight: 1.0,
297 headers: HashMap::new(),
298 body: None,
299 assertions: Vec::new(),
300 }
301 }
302
303 pub fn post(name: &str, path: &str, body: Option<String>) -> Self {
305 Self {
306 name: name.to_string(),
307 method: HttpMethod::Post,
308 path: path.to_string(),
309 weight: 1.0,
310 headers: HashMap::new(),
311 body,
312 assertions: Vec::new(),
313 }
314 }
315
316 pub fn with_assertion(mut self, assertion: LoadTestAssertion) -> Self {
318 self.assertions.push(assertion);
319 self
320 }
321
322 pub fn with_weight(mut self, weight: f64) -> Self {
324 self.weight = weight;
325 self
326 }
327}
328
329#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
331pub enum HttpMethod {
332 #[default]
334 Get,
335 Post,
337 Put,
339 Delete,
341 Patch,
343 Head,
345 Options,
347}
348
349impl std::fmt::Display for HttpMethod {
350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351 match self {
352 Self::Get => write!(f, "GET"),
353 Self::Post => write!(f, "POST"),
354 Self::Put => write!(f, "PUT"),
355 Self::Delete => write!(f, "DELETE"),
356 Self::Patch => write!(f, "PATCH"),
357 Self::Head => write!(f, "HEAD"),
358 Self::Options => write!(f, "OPTIONS"),
359 }
360 }
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
365#[serde(tag = "type")]
366pub enum LoadTestAssertion {
367 Status {
369 expected: u16,
371 },
372 LatencyPercentile {
374 percentile: u8,
376 max_ms: u64,
378 },
379 Header {
381 name: String,
383 expected: String,
385 },
386 BodyContains {
388 substring: String,
390 },
391}
392
393impl LoadTestAssertion {
394 pub fn status(expected: u16) -> Self {
396 Self::Status { expected }
397 }
398
399 pub fn latency_p95(max_ms: u64) -> Self {
401 Self::LatencyPercentile {
402 percentile: 95,
403 max_ms,
404 }
405 }
406
407 pub fn latency_percentile(percentile: u8, max_ms: u64) -> Self {
409 Self::LatencyPercentile { percentile, max_ms }
410 }
411
412 pub fn header(name: &str, expected: &str) -> Self {
414 Self::Header {
415 name: name.to_string(),
416 expected: expected.to_string(),
417 }
418 }
419
420 pub fn body_contains(substring: &str) -> Self {
422 Self::BodyContains {
423 substring: substring.to_string(),
424 }
425 }
426
427 pub fn description(&self) -> String {
429 match self {
430 Self::Status { expected } => format!("status == {}", expected),
431 Self::LatencyPercentile { percentile, max_ms } => {
432 format!("latency_p{} < {}ms", percentile, max_ms)
433 }
434 Self::Header { name, expected } => format!("{} == {}", name, expected),
435 Self::BodyContains { substring } => format!("body contains '{}'", substring),
436 }
437 }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct LoadTestResult {
447 pub scenario_name: String,
449 pub duration_secs: u64,
451 pub total_requests: u64,
453 pub successful_requests: u64,
455 pub failed_requests: u64,
457 pub endpoint_stats: Vec<EndpointStats>,
459 pub peak_throughput: f64,
461 pub peak_throughput_time: u64,
463 pub avg_throughput: f64,
465 pub resource_usage: ResourceUsage,
467 pub assertion_results: Vec<AssertionResult>,
469 pub errors: Vec<LoadTestError>,
471}
472
473impl LoadTestResult {
474 pub fn new(scenario_name: &str) -> Self {
476 Self {
477 scenario_name: scenario_name.to_string(),
478 duration_secs: 0,
479 total_requests: 0,
480 successful_requests: 0,
481 failed_requests: 0,
482 endpoint_stats: Vec::new(),
483 peak_throughput: 0.0,
484 peak_throughput_time: 0,
485 avg_throughput: 0.0,
486 resource_usage: ResourceUsage::default(),
487 assertion_results: Vec::new(),
488 errors: Vec::new(),
489 }
490 }
491
492 pub fn error_rate(&self) -> f64 {
494 if self.total_requests == 0 {
495 0.0
496 } else {
497 (self.failed_requests as f64 / self.total_requests as f64) * 100.0
498 }
499 }
500
501 pub fn all_assertions_passed(&self) -> bool {
503 self.assertion_results.iter().all(|r| r.passed)
504 }
505
506 pub fn passed_assertions(&self) -> usize {
508 self.assertion_results.iter().filter(|r| r.passed).count()
509 }
510
511 pub fn failed_assertions(&self) -> usize {
513 self.assertion_results.iter().filter(|r| !r.passed).count()
514 }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct EndpointStats {
520 pub name: String,
522 pub count: u64,
524 pub p50_ms: u64,
526 pub p95_ms: u64,
528 pub p99_ms: u64,
530 pub errors: u64,
532 pub min_ms: u64,
534 pub max_ms: u64,
536 pub avg_ms: u64,
538}
539
540impl EndpointStats {
541 pub fn new(name: &str) -> Self {
543 Self {
544 name: name.to_string(),
545 count: 0,
546 p50_ms: 0,
547 p95_ms: 0,
548 p99_ms: 0,
549 errors: 0,
550 min_ms: u64::MAX,
551 max_ms: 0,
552 avg_ms: 0,
553 }
554 }
555
556 pub fn from_samples(name: &str, samples: &[u64], errors: u64) -> Self {
558 if samples.is_empty() {
559 return Self::new(name);
560 }
561
562 let mut sorted = samples.to_vec();
563 sorted.sort_unstable();
564
565 let count = sorted.len() as u64;
566 let sum: u64 = sorted.iter().sum();
567
568 Self {
569 name: name.to_string(),
570 count,
571 p50_ms: percentile(&sorted, 50),
572 p95_ms: percentile(&sorted, 95),
573 p99_ms: percentile(&sorted, 99),
574 errors,
575 min_ms: *sorted.first().unwrap_or(&0),
576 max_ms: *sorted.last().unwrap_or(&0),
577 avg_ms: sum / count,
578 }
579 }
580}
581
582fn percentile(sorted: &[u64], p: u8) -> u64 {
584 if sorted.is_empty() {
585 return 0;
586 }
587 let idx = ((p as f64 / 100.0) * (sorted.len() - 1) as f64) as usize;
588 sorted[idx.min(sorted.len() - 1)]
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, Default)]
593pub struct ResourceUsage {
594 pub avg_cpu_percent: f64,
596 pub peak_cpu_percent: f64,
598 pub avg_memory_mb: u64,
600 pub peak_memory_mb: u64,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct AssertionResult {
607 pub endpoint: String,
609 pub assertion: String,
611 pub passed: bool,
613 pub actual: String,
615 pub expected: String,
617}
618
619impl AssertionResult {
620 pub fn passed(endpoint: &str, assertion: &str, actual: &str) -> Self {
622 Self {
623 endpoint: endpoint.to_string(),
624 assertion: assertion.to_string(),
625 passed: true,
626 actual: actual.to_string(),
627 expected: actual.to_string(),
628 }
629 }
630
631 pub fn failed(endpoint: &str, assertion: &str, expected: &str, actual: &str) -> Self {
633 Self {
634 endpoint: endpoint.to_string(),
635 assertion: assertion.to_string(),
636 passed: false,
637 actual: actual.to_string(),
638 expected: expected.to_string(),
639 }
640 }
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct LoadTestError {
646 pub endpoint: String,
648 pub kind: LoadTestErrorKind,
650 pub message: String,
652 pub time_secs: u64,
654}
655
656impl LoadTestError {
657 pub fn new(endpoint: &str, kind: LoadTestErrorKind, message: &str, time_secs: u64) -> Self {
659 Self {
660 endpoint: endpoint.to_string(),
661 kind,
662 message: message.to_string(),
663 time_secs,
664 }
665 }
666}
667
668#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
670pub enum LoadTestErrorKind {
671 Connection,
673 Timeout,
675 HttpError,
677 DnsError,
679 TlsError,
681 Other,
683}
684
685impl std::fmt::Display for LoadTestErrorKind {
686 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
687 match self {
688 Self::Connection => write!(f, "Connection"),
689 Self::Timeout => write!(f, "Timeout"),
690 Self::HttpError => write!(f, "HTTP Error"),
691 Self::DnsError => write!(f, "DNS Error"),
692 Self::TlsError => write!(f, "TLS Error"),
693 Self::Other => write!(f, "Other"),
694 }
695 }
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
704pub struct LatencyHistogram {
705 buckets: Vec<u64>,
707 bucket_size: u64,
709 count: u64,
711 sum: u64,
713 min: u64,
715 max: u64,
717}
718
719impl LatencyHistogram {
720 pub fn new(bucket_size: u64) -> Self {
722 Self {
723 buckets: vec![0; 1000], bucket_size,
725 count: 0,
726 sum: 0,
727 min: u64::MAX,
728 max: 0,
729 }
730 }
731
732 pub fn record(&mut self, latency_ms: u64) {
734 let bucket = (latency_ms / self.bucket_size) as usize;
735 if bucket < self.buckets.len() {
736 self.buckets[bucket] += 1;
737 } else {
738 *self.buckets.last_mut().unwrap() += 1;
740 }
741 self.count += 1;
742 self.sum += latency_ms;
743 self.min = self.min.min(latency_ms);
744 self.max = self.max.max(latency_ms);
745 }
746
747 pub fn percentile(&self, p: u8) -> u64 {
749 if self.count == 0 {
750 return 0;
751 }
752
753 let target = ((p as f64 / 100.0) * self.count as f64) as u64;
754 let mut cumulative = 0u64;
755
756 for (i, &count) in self.buckets.iter().enumerate() {
757 cumulative += count;
758 if cumulative >= target {
759 return (i as u64 + 1) * self.bucket_size;
760 }
761 }
762
763 self.max
764 }
765
766 pub fn mean(&self) -> u64 {
768 if self.count == 0 {
769 0
770 } else {
771 self.sum / self.count
772 }
773 }
774
775 pub fn count(&self) -> u64 {
777 self.count
778 }
779
780 pub fn min(&self) -> u64 {
782 if self.count == 0 {
783 0
784 } else {
785 self.min
786 }
787 }
788
789 pub fn max(&self) -> u64 {
791 self.max
792 }
793}
794
795impl Default for LatencyHistogram {
796 fn default() -> Self {
797 Self::new(1) }
799}
800
801pub fn render_load_test_report(result: &LoadTestResult) -> String {
807 let mut output = String::new();
808
809 output.push_str(&format!("LOAD TEST RESULTS: {}\n", result.scenario_name));
810 output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
811
812 output.push_str(&format!(
813 "Duration: {}s │ Total Requests: {} │ Failed: {} ({:.2}%)\n\n",
814 result.duration_secs,
815 result.total_requests,
816 result.failed_requests,
817 result.error_rate()
818 ));
819
820 output.push_str("Request Statistics:\n");
822 output.push_str("┌─────────────────┬─────────┬─────────┬─────────┬─────────┬─────────┐\n");
823 output.push_str("│ Endpoint │ Count │ p50 │ p95 │ p99 │ Errors │\n");
824 output.push_str("├─────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┤\n");
825
826 for stat in &result.endpoint_stats {
827 output.push_str(&format!(
828 "│ {:<15} │ {:>7} │ {:>5}ms │ {:>5}ms │ {:>5}ms │ {:>7} │\n",
829 truncate(&stat.name, 15),
830 stat.count,
831 stat.p50_ms,
832 stat.p95_ms,
833 stat.p99_ms,
834 stat.errors
835 ));
836 }
837 output.push_str("└─────────────────┴─────────┴─────────┴─────────┴─────────┴─────────┘\n\n");
838
839 output.push_str("Throughput:\n");
841 output.push_str(&format!(
842 " Peak: {:.0} req/s at t={}s\n",
843 result.peak_throughput, result.peak_throughput_time
844 ));
845 output.push_str(&format!(" Avg: {:.0} req/s\n\n", result.avg_throughput));
846
847 output.push_str("Resource Usage:\n");
849 output.push_str(&format!(
850 " Server CPU: avg {:.0}%, peak {:.0}%\n",
851 result.resource_usage.avg_cpu_percent, result.resource_usage.peak_cpu_percent
852 ));
853 output.push_str(&format!(
854 " Server Memory: avg {}MB, peak {}MB\n\n",
855 result.resource_usage.avg_memory_mb, result.resource_usage.peak_memory_mb
856 ));
857
858 output.push_str("Assertions:\n");
860 for assertion in &result.assertion_results {
861 let symbol = if assertion.passed { "✓" } else { "✗" };
862 output.push_str(&format!(
863 " {} {} {} (actual: {})\n",
864 symbol, assertion.endpoint, assertion.assertion, assertion.actual
865 ));
866 }
867
868 output
869}
870
871pub fn render_load_test_json(result: &LoadTestResult) -> String {
873 serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
874}
875
876fn truncate(s: &str, max_len: usize) -> String {
878 if s.len() <= max_len {
879 s.to_string()
880 } else {
881 format!("{}…", &s[..max_len - 1])
882 }
883}
884
885#[cfg(test)]
890#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn test_load_test_config_new() {
896 let config = LoadTestConfig::new("http://localhost:8080", 100, 30);
897 assert_eq!(config.target_url, "http://localhost:8080");
898 assert_eq!(config.users, UserConfig::Fixed(100));
899 assert_eq!(config.duration_secs, 30);
900 }
901
902 #[test]
903 fn test_load_test_config_ramp() {
904 let config = LoadTestConfig::with_ramp("http://localhost:8080", 1, 100, 60, 120);
905 assert_eq!(
906 config.users,
907 UserConfig::Ramp {
908 start: 1,
909 end: 100,
910 ramp_secs: 60
911 }
912 );
913 }
914
915 #[test]
916 fn test_user_config_fixed() {
917 let config = UserConfig::Fixed(50);
918 assert_eq!(config.users_at(0), 50);
919 assert_eq!(config.users_at(30), 50);
920 assert_eq!(config.users_at(100), 50);
921 }
922
923 #[test]
924 fn test_user_config_ramp() {
925 let config = UserConfig::Ramp {
926 start: 10,
927 end: 100,
928 ramp_secs: 90,
929 };
930 assert_eq!(config.users_at(0), 10);
931 assert_eq!(config.users_at(45), 55); assert_eq!(config.users_at(90), 100);
933 assert_eq!(config.users_at(100), 100); }
935
936 #[test]
937 fn test_scenario_new() {
938 let mut scenario = LoadTestScenario::new("Test", "A test scenario");
939 scenario.add_stage(LoadTestStage::steady("warmup", 30, 10));
940 scenario.add_stage(LoadTestStage::ramp("ramp", 60, 10, 100));
941
942 assert_eq!(scenario.stages.len(), 2);
943 assert_eq!(scenario.total_duration_secs(), 90);
944 }
945
946 #[test]
947 fn test_load_test_stage_steady() {
948 let stage = LoadTestStage::steady("steady", 60, 50);
949 assert!(!stage.is_ramp());
950 assert_eq!(stage.users_at(0), 50);
951 assert_eq!(stage.users_at(30), 50);
952 }
953
954 #[test]
955 fn test_load_test_stage_ramp() {
956 let stage = LoadTestStage::ramp("ramp", 60, 10, 100);
957 assert!(stage.is_ramp());
958 assert_eq!(stage.users_at(0), 10);
959 assert_eq!(stage.users_at(30), 55);
960 assert_eq!(stage.users_at(60), 100);
961 }
962
963 #[test]
964 fn test_load_test_request_get() {
965 let request = LoadTestRequest::get("home", "/")
966 .with_assertion(LoadTestAssertion::status(200))
967 .with_weight(2.0);
968
969 assert_eq!(request.name, "home");
970 assert_eq!(request.method, HttpMethod::Get);
971 assert_eq!(request.weight, 2.0);
972 assert_eq!(request.assertions.len(), 1);
973 }
974
975 #[test]
976 fn test_http_method_display() {
977 assert_eq!(HttpMethod::Get.to_string(), "GET");
978 assert_eq!(HttpMethod::Post.to_string(), "POST");
979 assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
980 }
981
982 #[test]
983 fn test_assertion_description() {
984 assert_eq!(
985 LoadTestAssertion::status(200).description(),
986 "status == 200"
987 );
988 assert_eq!(
989 LoadTestAssertion::latency_p95(100).description(),
990 "latency_p95 < 100ms"
991 );
992 assert_eq!(
993 LoadTestAssertion::header("content-type", "application/json").description(),
994 "content-type == application/json"
995 );
996 }
997
998 #[test]
999 fn test_load_test_result_error_rate() {
1000 let mut result = LoadTestResult::new("Test");
1001 result.total_requests = 1000;
1002 result.failed_requests = 10;
1003
1004 assert!((result.error_rate() - 1.0).abs() < 0.001);
1005 }
1006
1007 #[test]
1008 fn test_load_test_result_assertions() {
1009 let mut result = LoadTestResult::new("Test");
1010 result
1011 .assertion_results
1012 .push(AssertionResult::passed("ep1", "status", "200"));
1013 result
1014 .assertion_results
1015 .push(AssertionResult::passed("ep2", "status", "200"));
1016 result
1017 .assertion_results
1018 .push(AssertionResult::failed("ep3", "latency", "100ms", "200ms"));
1019
1020 assert_eq!(result.passed_assertions(), 2);
1021 assert_eq!(result.failed_assertions(), 1);
1022 assert!(!result.all_assertions_passed());
1023 }
1024
1025 #[test]
1026 fn test_endpoint_stats_from_samples() {
1027 let samples = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
1028 let stats = EndpointStats::from_samples("test", &samples, 2);
1029
1030 assert_eq!(stats.count, 10);
1031 assert_eq!(stats.min_ms, 10);
1032 assert_eq!(stats.max_ms, 100);
1033 assert_eq!(stats.avg_ms, 55);
1034 assert_eq!(stats.errors, 2);
1035 }
1036
1037 #[test]
1038 fn test_percentile() {
1039 let sorted = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
1040 assert_eq!(percentile(&sorted, 50), 5);
1041 assert_eq!(percentile(&sorted, 90), 9);
1042 assert_eq!(percentile(&sorted, 100), 10);
1043 }
1044
1045 #[test]
1046 fn test_latency_histogram() {
1047 let mut hist = LatencyHistogram::new(10);
1048
1049 for i in 0..100 {
1050 hist.record(i);
1051 }
1052
1053 assert_eq!(hist.count(), 100);
1054 assert_eq!(hist.min(), 0);
1055 assert_eq!(hist.max(), 99);
1056 }
1057
1058 #[test]
1059 fn test_latency_histogram_percentile() {
1060 let mut hist = LatencyHistogram::new(1);
1061
1062 for i in 1..=100 {
1063 hist.record(i);
1064 }
1065
1066 assert!(hist.percentile(50) >= 45 && hist.percentile(50) <= 55);
1068 assert!(hist.percentile(95) >= 90);
1069 }
1070
1071 #[test]
1072 fn test_load_test_error() {
1073 let error = LoadTestError::new(
1074 "endpoint",
1075 LoadTestErrorKind::Timeout,
1076 "Request timed out",
1077 45,
1078 );
1079 assert_eq!(error.endpoint, "endpoint");
1080 assert_eq!(error.kind, LoadTestErrorKind::Timeout);
1081 assert_eq!(error.time_secs, 45);
1082 }
1083
1084 #[test]
1085 fn test_error_kind_display() {
1086 assert_eq!(LoadTestErrorKind::Connection.to_string(), "Connection");
1087 assert_eq!(LoadTestErrorKind::Timeout.to_string(), "Timeout");
1088 assert_eq!(LoadTestErrorKind::HttpError.to_string(), "HTTP Error");
1089 }
1090
1091 #[test]
1092 fn test_render_load_test_report() {
1093 let mut result = LoadTestResult::new("Test Scenario");
1094 result.duration_secs = 60;
1095 result.total_requests = 1000;
1096 result.successful_requests = 990;
1097 result.failed_requests = 10;
1098 result.avg_throughput = 16.67;
1099 result.peak_throughput = 25.0;
1100 result.peak_throughput_time = 30;
1101
1102 result.endpoint_stats.push(EndpointStats {
1103 name: "homepage".to_string(),
1104 count: 500,
1105 p50_ms: 12,
1106 p95_ms: 45,
1107 p99_ms: 89,
1108 errors: 5,
1109 min_ms: 5,
1110 max_ms: 120,
1111 avg_ms: 25,
1112 });
1113
1114 result
1115 .assertion_results
1116 .push(AssertionResult::passed("homepage", "status == 200", "200"));
1117
1118 let report = render_load_test_report(&result);
1119 assert!(report.contains("Test Scenario"));
1120 assert!(report.contains("Duration: 60s"));
1121 assert!(report.contains("homepage"));
1122 assert!(report.contains("✓"));
1123 }
1124
1125 #[test]
1126 fn test_render_load_test_json() {
1127 let result = LoadTestResult::new("JSON Test");
1128 let json = render_load_test_json(&result);
1129 assert!(json.contains("JSON Test"));
1130 assert!(json.contains("scenario_name"));
1131 }
1132
1133 #[test]
1134 fn test_truncate() {
1135 assert_eq!(truncate("short", 10), "short");
1136 assert_eq!(truncate("this is very long", 10), "this is v…");
1137 }
1138
1139 #[test]
1140 fn test_scenario_yaml_roundtrip() {
1141 let mut scenario = LoadTestScenario::new("YAML Test", "Testing YAML serialization");
1142 scenario.add_stage(LoadTestStage::steady("warmup", 10, 5));
1143 scenario.add_request(LoadTestRequest::get("home", "/"));
1144
1145 let yaml = serde_yaml_ng::to_string(&scenario).unwrap();
1146 let parsed: LoadTestScenario = serde_yaml_ng::from_str(&yaml).unwrap();
1147
1148 assert_eq!(parsed.name, "YAML Test");
1149 assert_eq!(parsed.stages.len(), 1);
1150 assert_eq!(parsed.requests.len(), 1);
1151 }
1152
1153 #[test]
1154 fn test_load_test_config_from_scenario() {
1155 let config = LoadTestConfig::from_scenario(PathBuf::from("scenario.yaml"));
1156 assert!(config.target_url.is_empty());
1157 assert_eq!(config.users, UserConfig::Fixed(1));
1158 assert_eq!(config.duration_secs, 0);
1159 assert_eq!(config.scenario, Some(PathBuf::from("scenario.yaml")));
1160 }
1161
1162 #[test]
1163 fn test_load_test_assertion_body_contains() {
1164 let assertion = LoadTestAssertion::body_contains("success");
1165 assert_eq!(assertion.description(), "body contains 'success'");
1166 }
1167
1168 #[test]
1169 fn test_load_test_assertion_latency_percentile() {
1170 let assertion = LoadTestAssertion::latency_percentile(99, 500);
1171 assert_eq!(assertion.description(), "latency_p99 < 500ms");
1172 }
1173
1174 #[test]
1175 fn test_load_test_result_error_rate_zero() {
1176 let result = LoadTestResult::new("Zero Requests");
1177 assert_eq!(result.error_rate(), 0.0);
1178 }
1179
1180 #[test]
1181 fn test_load_test_result_all_assertions_passed_empty() {
1182 let result = LoadTestResult::new("No Assertions");
1183 assert!(result.all_assertions_passed());
1184 }
1185
1186 #[test]
1187 fn test_endpoint_stats_new() {
1188 let stats = EndpointStats::new("api/v1");
1189 assert_eq!(stats.name, "api/v1");
1190 assert_eq!(stats.count, 0);
1191 }
1192
1193 #[test]
1194 fn test_endpoint_stats_from_empty_samples() {
1195 let stats = EndpointStats::from_samples("empty", &[], 0);
1196 assert_eq!(stats.count, 0);
1197 }
1198
1199 #[test]
1200 fn test_percentile_empty() {
1201 let empty: Vec<u64> = vec![];
1202 assert_eq!(percentile(&empty, 50), 0);
1203 }
1204
1205 #[test]
1206 fn test_http_method_all_variants() {
1207 assert_eq!(HttpMethod::Get.to_string(), "GET");
1208 assert_eq!(HttpMethod::Post.to_string(), "POST");
1209 assert_eq!(HttpMethod::Put.to_string(), "PUT");
1210 assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
1211 assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
1212 assert_eq!(HttpMethod::Head.to_string(), "HEAD");
1213 assert_eq!(HttpMethod::Options.to_string(), "OPTIONS");
1214 }
1215
1216 #[test]
1217 fn test_error_kind_all_variants() {
1218 assert_eq!(LoadTestErrorKind::Connection.to_string(), "Connection");
1219 assert_eq!(LoadTestErrorKind::Timeout.to_string(), "Timeout");
1220 assert_eq!(LoadTestErrorKind::HttpError.to_string(), "HTTP Error");
1221 assert_eq!(LoadTestErrorKind::DnsError.to_string(), "DNS Error");
1222 assert_eq!(LoadTestErrorKind::TlsError.to_string(), "TLS Error");
1223 assert_eq!(LoadTestErrorKind::Other.to_string(), "Other");
1224 }
1225
1226 #[test]
1227 fn test_load_test_request_post() {
1228 let request = LoadTestRequest::post(
1229 "create_user",
1230 "/users",
1231 Some(r#"{"name": "test"}"#.to_string()),
1232 );
1233 assert_eq!(request.method, HttpMethod::Post);
1234 assert!(request.body.is_some());
1235 }
1236
1237 #[test]
1238 fn test_load_test_request_post_no_body() {
1239 let request = LoadTestRequest::post("create_empty", "/items", None);
1240 assert_eq!(request.method, HttpMethod::Post);
1241 assert!(request.body.is_none());
1242 }
1243
1244 #[test]
1245 fn test_latency_histogram_empty_percentile() {
1246 let hist = LatencyHistogram::new(10);
1247 assert_eq!(hist.percentile(50), 0);
1248 }
1249
1250 #[test]
1251 fn test_latency_histogram_empty_stats() {
1252 let hist = LatencyHistogram::new(10);
1253 assert_eq!(hist.count(), 0);
1254 assert_eq!(hist.min(), 0); assert_eq!(hist.max(), 0);
1256 }
1257
1258 #[test]
1259 fn test_latency_histogram_overflow() {
1260 let mut hist = LatencyHistogram::new(10); hist.record(10000); assert_eq!(hist.count(), 1);
1263 }
1264
1265 #[test]
1266 fn test_assertion_result_passed() {
1267 let result = AssertionResult::passed("endpoint", "status == 200", "200");
1268 assert!(result.passed);
1269 assert_eq!(result.endpoint, "endpoint");
1270 }
1271
1272 #[test]
1273 fn test_assertion_result_failed() {
1274 let result = AssertionResult::failed("endpoint", "latency_p95 < 100ms", "100ms", "150ms");
1275 assert!(!result.passed);
1276 assert!(!result.expected.is_empty());
1277 assert!(!result.actual.is_empty());
1278 }
1279
1280 #[test]
1281 fn test_resource_usage_default() {
1282 let usage = ResourceUsage::default();
1283 assert_eq!(usage.avg_cpu_percent, 0.0);
1284 assert_eq!(usage.peak_cpu_percent, 0.0);
1285 }
1286
1287 #[test]
1288 fn test_load_test_output_format_default() {
1289 let format = LoadTestOutputFormat::default();
1290 assert!(matches!(format, LoadTestOutputFormat::Text));
1291 }
1292}