use crate::error::TarnError;
use crate::http;
use crate::interpolation::{self, Context};
use crate::model::{AuthConfig, HttpTransportConfig, HttpVersionPreference, Step, TestFile};
use base64::Engine;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
#[derive(Debug, Clone)]
pub struct BenchOptions {
pub requests: u64,
pub concurrency: u64,
pub ramp_up: Option<Duration>,
pub thresholds: BenchThresholds,
}
#[derive(Debug, Clone, Default)]
pub struct BenchThresholds {
pub min_throughput_rps: Option<f64>,
pub max_error_rate: Option<f64>,
pub max_p95_ms: Option<u64>,
pub max_p99_ms: Option<u64>,
}
#[derive(Debug, Clone)]
struct RequestResult {
status: u16,
success: bool,
error: Option<String>,
timings: RequestTimings,
}
#[derive(Debug, Clone)]
struct RequestTimings {
total_ms: u64,
ttfb_ms: u64,
body_read_ms: u64,
}
#[derive(Debug, Clone)]
enum BenchPayload {
Json(serde_json::Value),
Form(IndexMap<String, String>),
}
fn effective_auth<'a>(step: &'a Step, test_file: &'a TestFile) -> Option<&'a AuthConfig> {
step.request.auth.as_ref().or_else(|| {
test_file
.defaults
.as_ref()
.and_then(|defaults| defaults.auth.as_ref())
})
}
fn apply_auth_header(
headers: &mut HashMap<String, String>,
auth: Option<&AuthConfig>,
ctx: &Context,
) {
if headers
.keys()
.any(|key| key.eq_ignore_ascii_case("authorization"))
{
return;
}
let Some(auth) = auth else {
return;
};
if let Some(token) = auth.bearer.as_ref() {
headers.insert(
"Authorization".into(),
format!("Bearer {}", interpolation::interpolate(token, ctx)),
);
} else if let Some(basic) = auth.basic.as_ref() {
let username = interpolation::interpolate(&basic.username, ctx);
let password = interpolation::interpolate(&basic.password, ctx);
let encoded =
base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
headers.insert("Authorization".into(), format!("Basic {encoded}"));
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BenchResult {
pub step_name: String,
pub method: String,
pub url: String,
pub concurrency: u64,
pub ramp_up_ms: Option<u64>,
pub total_requests: u64,
pub successful: u64,
pub failed: u64,
pub error_rate: f64,
pub total_duration_ms: u64,
pub throughput_rps: f64,
pub latency: LatencyStats,
pub timings: TimingBreakdown,
pub status_codes: HashMap<u16, u64>,
pub errors: Vec<String>,
pub gates: Vec<BenchGateResult>,
pub passed_gates: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct LatencyStats {
pub min_ms: u64,
pub max_ms: u64,
pub mean_ms: f64,
pub median_ms: u64,
pub p95_ms: u64,
pub p99_ms: u64,
pub stdev_ms: f64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TimingBreakdown {
pub total: LatencyStats,
pub ttfb: LatencyStats,
pub body_read: LatencyStats,
pub connect: Option<LatencyStats>,
pub tls: Option<LatencyStats>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BenchGateResult {
pub name: String,
pub passed: bool,
pub expected: String,
pub actual: String,
pub message: String,
}
pub fn run_bench(
test_file: &TestFile,
step_index: usize,
env: &HashMap<String, String>,
opts: &BenchOptions,
http_config: &HttpTransportConfig,
) -> Result<BenchResult, TarnError> {
let step = resolve_step(test_file, step_index)?;
let ctx = Context {
env: env.clone(),
captures: HashMap::new(),
optional_unset: std::collections::HashSet::new(),
};
let url = interpolation::interpolate(&step.request.url, &ctx);
let mut merged_headers = test_file
.defaults
.as_ref()
.map(|d| d.headers.clone())
.unwrap_or_default();
for (k, v) in &step.request.headers {
merged_headers.insert(k.clone(), v.clone());
}
apply_auth_header(&mut merged_headers, effective_auth(step, test_file), &ctx);
let payload = if let Some(ref form) = step.request.form {
let form = interpolation::interpolate_string_map(form, &ctx);
merged_headers
.entry("Content-Type".to_string())
.or_insert_with(|| "application/x-www-form-urlencoded".to_string());
Some(BenchPayload::Form(form))
} else {
step.request
.body
.as_ref()
.map(|b| BenchPayload::Json(interpolation::interpolate_json(b, &ctx)))
};
let headers = interpolation::interpolate_headers(&merged_headers, &ctx);
let method = step.request.method.clone();
let step_name = step.name.clone();
let expected_status = step.assertions.as_ref().and_then(|a| {
a.status.as_ref().and_then(|s| match s {
crate::model::StatusAssertion::Exact(code) => Some(*code),
_ => None, })
});
let bench_req = BenchRequest {
step_name: &step_name,
method: &method,
url: &url,
headers: &headers,
payload: payload.as_ref(),
expected_status,
};
let rt = tokio::runtime::Runtime::new()
.map_err(|e| TarnError::Http(format!("Failed to create async runtime: {}", e)))?;
let result = rt.block_on(run_bench_async(&bench_req, opts, http_config))?;
Ok(result)
}
struct BenchRequest<'a> {
step_name: &'a str,
method: &'a str,
url: &'a str,
headers: &'a HashMap<String, String>,
payload: Option<&'a BenchPayload>,
expected_status: Option<u16>,
}
async fn run_bench_async(
req: &BenchRequest<'_>,
opts: &BenchOptions,
http_config: &HttpTransportConfig,
) -> Result<BenchResult, TarnError> {
let semaphore = Arc::new(Semaphore::new(opts.concurrency as usize));
let completed = Arc::new(AtomicU64::new(0));
let client = http::build_async_client_with_timeout(http_config, Some(Duration::from_secs(30)))?;
let overall_start = Instant::now();
let http_version = http_config.http_version;
let mut handles = Vec::with_capacity(opts.requests as usize);
for i in 0..opts.requests {
if let Some(ramp) = opts.ramp_up {
if i < opts.concurrency {
let delay_per_worker = ramp / opts.concurrency as u32;
let delay = delay_per_worker * i as u32;
tokio::time::sleep(delay).await;
}
}
let permit = semaphore.clone().acquire_owned().await.unwrap();
let client = client.clone();
let method = req.method.to_string();
let url = req.url.to_string();
let headers = req.headers.clone();
let payload = req.payload.cloned();
let completed = completed.clone();
let expected_status = req.expected_status;
let handle = tokio::spawn(async move {
let result = execute_single(
&client,
&method,
&url,
&headers,
payload.as_ref(),
expected_status,
http_version,
)
.await;
completed.fetch_add(1, Ordering::Relaxed);
drop(permit);
result
});
handles.push(handle);
}
let mut results = Vec::with_capacity(opts.requests as usize);
for handle in handles {
match handle.await {
Ok(r) => results.push(r),
Err(e) => results.push(RequestResult {
status: 0,
success: false,
error: Some(format!("Task failed: {}", e)),
timings: RequestTimings {
total_ms: 0,
ttfb_ms: 0,
body_read_ms: 0,
},
}),
}
}
let total_duration_ms = overall_start.elapsed().as_millis() as u64;
let mut result = aggregate_results(
req.step_name,
req.method,
req.url,
opts.concurrency,
opts.ramp_up,
results,
total_duration_ms,
);
result.gates = evaluate_gates(&result, &opts.thresholds);
result.passed_gates = result.gates.iter().all(|gate| gate.passed);
Ok(result)
}
async fn execute_single(
client: &reqwest::Client,
method: &str,
url: &str,
headers: &HashMap<String, String>,
payload: Option<&BenchPayload>,
expected_status: Option<u16>,
http_version: Option<HttpVersionPreference>,
) -> RequestResult {
let req_method = match reqwest::Method::from_bytes(method.trim().as_bytes()) {
Ok(method) => method,
Err(error) => {
return RequestResult {
status: 0,
success: false,
error: Some(format!("Invalid HTTP method '{}': {}", method, error)),
timings: RequestTimings {
total_ms: 0,
ttfb_ms: 0,
body_read_ms: 0,
},
}
}
};
let mut builder = client.request(req_method, url);
builder = match http_version {
Some(HttpVersionPreference::Http1_1) => builder.version(reqwest::Version::HTTP_11),
Some(HttpVersionPreference::Http2) => builder.version(reqwest::Version::HTTP_2),
None => builder,
};
for (k, v) in headers {
builder = builder.header(k, v);
}
if let Some(payload) = payload {
builder = match payload {
BenchPayload::Json(body) => builder.json(body),
BenchPayload::Form(form) => match http::encode_form_body(form) {
Ok(body) => builder.body(body),
Err(error) => {
return RequestResult {
status: 0,
success: false,
error: Some(error.to_string()),
timings: RequestTimings {
total_ms: 0,
ttfb_ms: 0,
body_read_ms: 0,
},
}
}
},
};
}
let start = Instant::now();
match builder.send().await {
Ok(resp) => {
let status = resp.status().as_u16();
let ttfb_ms = start.elapsed().as_millis() as u64;
let body_start = Instant::now();
let _ = resp.bytes().await;
let body_read_ms = body_start.elapsed().as_millis() as u64;
let duration_ms = ttfb_ms.saturating_add(body_read_ms);
let success = expected_status.map(|e| e == status).unwrap_or(true);
RequestResult {
status,
success,
error: if success {
None
} else {
Some(format!(
"Expected status {}, got {}",
expected_status.unwrap(),
status
))
},
timings: RequestTimings {
total_ms: duration_ms,
ttfb_ms,
body_read_ms,
},
}
}
Err(e) => RequestResult {
status: 0,
success: false,
error: Some(e.to_string()),
timings: RequestTimings {
total_ms: 0,
ttfb_ms: 0,
body_read_ms: 0,
},
},
}
}
fn aggregate_results(
step_name: &str,
method: &str,
url: &str,
concurrency: u64,
ramp_up: Option<Duration>,
results: Vec<RequestResult>,
total_duration_ms: u64,
) -> BenchResult {
let total = results.len() as u64;
let successful = results.iter().filter(|r| r.success).count() as u64;
let failed = total - successful;
let mut total_latencies: Vec<u64> = results
.iter()
.filter(|r| r.success)
.map(|r| r.timings.total_ms)
.collect();
let mut ttfb_latencies: Vec<u64> = results
.iter()
.filter(|r| r.success)
.map(|r| r.timings.ttfb_ms)
.collect();
let mut body_read_latencies: Vec<u64> = results
.iter()
.filter(|r| r.success)
.map(|r| r.timings.body_read_ms)
.collect();
total_latencies.sort();
ttfb_latencies.sort();
body_read_latencies.sort();
let latency = summarize_latencies(&total_latencies);
let timings = TimingBreakdown {
total: latency.clone(),
ttfb: summarize_latencies(&ttfb_latencies),
body_read: summarize_latencies(&body_read_latencies),
connect: None,
tls: None,
};
let mut status_codes: HashMap<u16, u64> = HashMap::new();
for r in &results {
if r.status > 0 {
*status_codes.entry(r.status).or_insert(0) += 1;
}
}
let mut errors: Vec<String> = Vec::new();
for r in &results {
if let Some(ref e) = r.error {
if errors.len() < 10 && !errors.contains(e) {
errors.push(e.clone());
}
}
}
let throughput = if total_duration_ms > 0 {
(total as f64 / total_duration_ms as f64) * 1000.0
} else {
0.0
};
BenchResult {
step_name: step_name.to_string(),
method: method.to_string(),
url: url.to_string(),
concurrency,
ramp_up_ms: ramp_up.map(|duration| duration.as_millis() as u64),
total_requests: total,
successful,
failed,
error_rate: if total > 0 {
(failed as f64 / total as f64) * 100.0
} else {
0.0
},
total_duration_ms,
throughput_rps: (throughput * 100.0).round() / 100.0,
latency,
timings,
status_codes,
errors,
gates: Vec::new(),
passed_gates: true,
}
}
fn summarize_latencies(latencies: &[u64]) -> LatencyStats {
if latencies.is_empty() {
return LatencyStats {
min_ms: 0,
max_ms: 0,
mean_ms: 0.0,
median_ms: 0,
p95_ms: 0,
p99_ms: 0,
stdev_ms: 0.0,
};
}
let min = *latencies.first().unwrap();
let max = *latencies.last().unwrap();
let sum: u64 = latencies.iter().sum();
let mean = sum as f64 / latencies.len() as f64;
let median = percentile(latencies, 50.0);
let p95 = percentile(latencies, 95.0);
let p99 = percentile(latencies, 99.0);
let variance = latencies
.iter()
.map(|&value| {
let diff = value as f64 - mean;
diff * diff
})
.sum::<f64>()
/ latencies.len() as f64;
LatencyStats {
min_ms: min,
max_ms: max,
mean_ms: (mean * 100.0).round() / 100.0,
median_ms: median,
p95_ms: p95,
p99_ms: p99,
stdev_ms: (variance.sqrt() * 100.0).round() / 100.0,
}
}
fn evaluate_gates(result: &BenchResult, thresholds: &BenchThresholds) -> Vec<BenchGateResult> {
let mut gates = Vec::new();
if let Some(min_rps) = thresholds.min_throughput_rps {
let passed = result.throughput_rps >= min_rps;
gates.push(BenchGateResult {
name: "throughput_rps".into(),
passed,
expected: format!(">= {:.2}", min_rps),
actual: format!("{:.2}", result.throughput_rps),
message: if passed {
"Throughput gate passed".into()
} else {
"Throughput dropped below the configured floor".into()
},
});
}
if let Some(max_error_rate) = thresholds.max_error_rate {
let passed = result.error_rate <= max_error_rate;
gates.push(BenchGateResult {
name: "error_rate".into(),
passed,
expected: format!("<= {:.2}", max_error_rate),
actual: format!("{:.2}", result.error_rate),
message: if passed {
"Error-rate gate passed".into()
} else {
"Error rate exceeded the configured ceiling".into()
},
});
}
if let Some(max_p95_ms) = thresholds.max_p95_ms {
let passed = result.latency.p95_ms <= max_p95_ms;
gates.push(BenchGateResult {
name: "latency_p95_ms".into(),
passed,
expected: format!("<= {}", max_p95_ms),
actual: result.latency.p95_ms.to_string(),
message: if passed {
"P95 latency gate passed".into()
} else {
"P95 latency exceeded the configured ceiling".into()
},
});
}
if let Some(max_p99_ms) = thresholds.max_p99_ms {
let passed = result.latency.p99_ms <= max_p99_ms;
gates.push(BenchGateResult {
name: "latency_p99_ms".into(),
passed,
expected: format!("<= {}", max_p99_ms),
actual: result.latency.p99_ms.to_string(),
message: if passed {
"P99 latency gate passed".into()
} else {
"P99 latency exceeded the configured ceiling".into()
},
});
}
gates
}
fn percentile(sorted: &[u64], pct: f64) -> u64 {
if sorted.is_empty() {
return 0;
}
let idx = ((pct / 100.0) * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn resolve_step(test_file: &TestFile, step_index: usize) -> Result<&Step, TarnError> {
if !test_file.steps.is_empty() {
return test_file.steps.get(step_index).ok_or_else(|| {
TarnError::Config(format!(
"Step index {} out of range (file has {} steps)",
step_index,
test_file.steps.len()
))
});
}
if let Some((_, group)) = test_file.tests.iter().next() {
return group.steps.get(step_index).ok_or_else(|| {
TarnError::Config(format!(
"Step index {} out of range (test has {} steps)",
step_index,
group.steps.len()
))
});
}
Err(TarnError::Config("No steps found in test file".into()))
}
pub fn render_human(result: &BenchResult) -> String {
use colored::Colorize;
let mut out = String::new();
out.push_str(&format!(
"\n {} {} {} — {} requests, {} concurrent\n\n",
"TARN BENCH".bold().white().on_blue(),
result.method.bold(),
result.url.dimmed(),
result.total_requests,
result.concurrency,
));
let ok_str = result.successful.to_string().green();
let fail_str = if result.failed > 0 {
result.failed.to_string().red()
} else {
result.failed.to_string().dimmed()
};
out.push_str(&format!(
" {:<14} {} total, {} ok, {} failed ({:.1}%)\n",
"Requests:".bold(),
result.total_requests,
ok_str,
fail_str,
result.error_rate
));
let dur = if result.total_duration_ms >= 1000 {
format!("{:.2}s", result.total_duration_ms as f64 / 1000.0)
} else {
format!("{}ms", result.total_duration_ms)
};
out.push_str(&format!(" {:<14} {}\n", "Duration:".bold(), dur));
out.push_str(&format!(
" {:<14} {:.1} req/s\n",
"Throughput:".bold(),
result.throughput_rps
));
out.push_str(&format!("\n {}:\n", "Latency".bold()));
out.push_str(&format!(" {:<10} {}ms\n", "min", result.latency.min_ms));
out.push_str(&format!(
" {:<10} {}ms\n",
"p50", result.latency.median_ms
));
out.push_str(&format!(
" {:<10} {}ms\n",
"p95".yellow(),
result.latency.p95_ms.to_string().yellow()
));
out.push_str(&format!(
" {:<10} {}ms\n",
"p99".red(),
result.latency.p99_ms.to_string().red()
));
out.push_str(&format!(" {:<10} {}ms\n", "max", result.latency.max_ms));
out.push_str(&format!(
" {:<10} {:.2}ms\n",
"stdev", result.latency.stdev_ms
));
out.push_str(&format!("\n {}:\n", "Timings".bold()));
out.push_str(&format!(
" {:<10} p50={}ms p95={}ms p99={}ms\n",
"ttfb",
result.timings.ttfb.median_ms,
result.timings.ttfb.p95_ms,
result.timings.ttfb.p99_ms
));
out.push_str(&format!(
" {:<10} p50={}ms p95={}ms p99={}ms\n",
"body-read",
result.timings.body_read.median_ms,
result.timings.body_read.p95_ms,
result.timings.body_read.p99_ms
));
out.push_str(" connect n/a (reqwest client does not expose phase timing)\n");
out.push_str(" tls n/a (reqwest client does not expose phase timing)\n");
if !result.status_codes.is_empty() {
out.push_str(&format!("\n {}:\n", "Status codes".bold()));
let mut codes: Vec<_> = result.status_codes.iter().collect();
codes.sort_by_key(|(code, _)| *code);
for (code, count) in codes {
let code_str = if *code >= 200 && *code < 300 {
code.to_string().green().to_string()
} else if *code >= 400 {
code.to_string().red().to_string()
} else {
code.to_string()
};
out.push_str(&format!(" {} — {} responses\n", code_str, count));
}
}
if !result.errors.is_empty() {
out.push_str(&format!("\n {}:\n", "Errors".bold().red()));
for e in &result.errors {
out.push_str(&format!(" - {}\n", e.red()));
}
}
if !result.gates.is_empty() {
out.push_str(&format!("\n {}:\n", "CI gates".bold()));
for gate in &result.gates {
let status = if gate.passed {
"PASS".green().to_string()
} else {
"FAIL".red().to_string()
};
out.push_str(&format!(
" [{}] {} expected {} actual {}\n",
status, gate.name, gate.expected, gate.actual
));
}
}
out.push('\n');
out
}
pub fn render_json(result: &BenchResult) -> String {
serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
}
pub fn render_csv(result: &BenchResult) -> String {
let mut lines = vec![[
"step_name",
"method",
"url",
"concurrency",
"requests",
"successful",
"failed",
"error_rate",
"throughput_rps",
"latency_p50_ms",
"latency_p95_ms",
"latency_p99_ms",
"ttfb_p50_ms",
"ttfb_p95_ms",
"ttfb_p99_ms",
"body_read_p50_ms",
"body_read_p95_ms",
"body_read_p99_ms",
"passed_gates",
]
.join(",")];
lines.push(
vec![
csv_escape(&result.step_name),
csv_escape(&result.method),
csv_escape(&result.url),
result.concurrency.to_string(),
result.total_requests.to_string(),
result.successful.to_string(),
result.failed.to_string(),
format!("{:.2}", result.error_rate),
format!("{:.2}", result.throughput_rps),
result.latency.median_ms.to_string(),
result.latency.p95_ms.to_string(),
result.latency.p99_ms.to_string(),
result.timings.ttfb.median_ms.to_string(),
result.timings.ttfb.p95_ms.to_string(),
result.timings.ttfb.p99_ms.to_string(),
result.timings.body_read.median_ms.to_string(),
result.timings.body_read.p95_ms.to_string(),
result.timings.body_read.p99_ms.to_string(),
result.passed_gates.to_string(),
]
.join(","),
);
lines.join("\n") + "\n"
}
fn csv_escape(value: &str) -> String {
if value.contains([',', '"', '\n']) {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request_result(
duration_ms: u64,
status: u16,
success: bool,
error: Option<&str>,
) -> RequestResult {
RequestResult {
status,
success,
error: error.map(str::to_string),
timings: RequestTimings {
total_ms: duration_ms,
ttfb_ms: duration_ms / 2,
body_read_ms: duration_ms.saturating_sub(duration_ms / 2),
},
}
}
fn sample_bench_result() -> BenchResult {
BenchResult {
step_name: "test".into(),
method: "GET".into(),
url: "http://localhost".into(),
concurrency: 10,
ramp_up_ms: None,
total_requests: 10,
successful: 9,
failed: 1,
error_rate: 10.0,
total_duration_ms: 500,
throughput_rps: 20.0,
latency: LatencyStats {
min_ms: 5,
max_ms: 50,
mean_ms: 20.0,
median_ms: 18,
p95_ms: 45,
p99_ms: 50,
stdev_ms: 12.5,
},
timings: TimingBreakdown {
total: LatencyStats {
min_ms: 5,
max_ms: 50,
mean_ms: 20.0,
median_ms: 18,
p95_ms: 45,
p99_ms: 50,
stdev_ms: 12.5,
},
ttfb: LatencyStats {
min_ms: 2,
max_ms: 20,
mean_ms: 8.0,
median_ms: 7,
p95_ms: 18,
p99_ms: 20,
stdev_ms: 3.5,
},
body_read: LatencyStats {
min_ms: 1,
max_ms: 30,
mean_ms: 12.0,
median_ms: 10,
p95_ms: 27,
p99_ms: 30,
stdev_ms: 7.0,
},
connect: None,
tls: None,
},
status_codes: HashMap::from([(200, 9), (500, 1)]),
errors: vec!["server error".into()],
gates: Vec::new(),
passed_gates: true,
}
}
#[tokio::test]
async fn execute_single_rejects_invalid_method_token() {
let client = reqwest::Client::new();
let result = execute_single(
&client,
"BAD METHOD",
"http://127.0.0.1:1",
&HashMap::new(),
None,
None,
None,
)
.await;
assert!(!result.success);
assert!(result.error.unwrap().contains("Invalid HTTP method"));
}
#[test]
fn percentile_empty() {
assert_eq!(percentile(&[], 50.0), 0);
}
#[test]
fn percentile_single() {
assert_eq!(percentile(&[42], 50.0), 42);
assert_eq!(percentile(&[42], 99.0), 42);
}
#[test]
fn percentile_multiple() {
let data: Vec<u64> = (1..=100).collect();
assert_eq!(percentile(&data, 50.0), 51);
assert_eq!(percentile(&data, 95.0), 95);
assert_eq!(percentile(&data, 99.0), 99);
}
#[test]
fn percentile_small_set() {
let data = vec![5, 10, 15, 20, 25];
assert_eq!(percentile(&data, 50.0), 15);
}
#[test]
fn aggregate_all_success() {
let results = vec![
request_result(10, 200, true, None),
request_result(20, 200, true, None),
request_result(30, 200, true, None),
];
let agg = aggregate_results("test", "GET", "http://localhost", 3, None, results, 100);
assert_eq!(agg.total_requests, 3);
assert_eq!(agg.successful, 3);
assert_eq!(agg.failed, 0);
assert_eq!(agg.error_rate, 0.0);
assert_eq!(agg.latency.min_ms, 10);
assert_eq!(agg.latency.max_ms, 30);
assert_eq!(agg.latency.median_ms, 20);
assert_eq!(*agg.status_codes.get(&200).unwrap(), 3);
}
#[test]
fn aggregate_mixed_results() {
let results = vec![
request_result(10, 200, true, None),
request_result(5, 500, false, Some("server error")),
request_result(0, 0, false, Some("connection refused")),
];
let agg = aggregate_results("test", "GET", "http://localhost", 2, None, results, 50);
assert_eq!(agg.total_requests, 3);
assert_eq!(agg.successful, 1);
assert_eq!(agg.failed, 2);
assert!(agg.error_rate > 60.0);
assert_eq!(agg.errors.len(), 2);
assert_eq!(agg.latency.min_ms, 10);
assert_eq!(agg.latency.max_ms, 10);
}
#[test]
fn aggregate_all_failures() {
let results = vec![request_result(0, 0, false, Some("err"))];
let agg = aggregate_results("test", "GET", "http://localhost", 1, None, results, 10);
assert_eq!(agg.successful, 0);
assert_eq!(agg.failed, 1);
assert_eq!(agg.latency.min_ms, 0);
assert_eq!(agg.latency.mean_ms, 0.0);
}
#[test]
fn aggregate_throughput() {
let results = vec![
request_result(10, 200, true, None),
request_result(10, 200, true, None),
];
let agg = aggregate_results("test", "GET", "http://localhost", 2, None, results, 100);
assert_eq!(agg.throughput_rps, 20.0);
}
#[test]
fn aggregate_deduplicates_errors() {
let results = vec![
request_result(0, 0, false, Some("same error")),
request_result(0, 0, false, Some("same error")),
request_result(0, 0, false, Some("different")),
];
let agg = aggregate_results("test", "GET", "http://localhost", 1, None, results, 10);
assert_eq!(agg.errors.len(), 2);
}
#[test]
fn resolve_step_flat_steps() {
let yaml = r#"
name: test
steps:
- name: first
request:
method: GET
url: "http://localhost"
- name: second
request:
method: POST
url: "http://localhost"
"#;
let tf: crate::model::TestFile = serde_yaml::from_str(yaml).unwrap();
let step = resolve_step(&tf, 0).unwrap();
assert_eq!(step.name, "first");
let step = resolve_step(&tf, 1).unwrap();
assert_eq!(step.name, "second");
assert!(resolve_step(&tf, 5).is_err());
}
#[test]
fn resolve_step_test_groups() {
let yaml = r#"
name: test
tests:
my_test:
steps:
- name: grouped
request:
method: GET
url: "http://localhost"
"#;
let tf: crate::model::TestFile = serde_yaml::from_str(yaml).unwrap();
let step = resolve_step(&tf, 0).unwrap();
assert_eq!(step.name, "grouped");
}
#[test]
fn render_json_output() {
let result = sample_bench_result();
let json = render_json(&result);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["total_requests"], 10);
assert_eq!(parsed["latency"]["p95_ms"], 45);
assert_eq!(parsed["timings"]["ttfb"]["p95_ms"], 18);
}
#[test]
fn render_human_output() {
let mut result = sample_bench_result();
result.step_name = "health".into();
result.url = "http://localhost/health".into();
result.total_requests = 100;
result.successful = 100;
result.failed = 0;
result.error_rate = 0.0;
result.total_duration_ms = 1500;
result.throughput_rps = 66.67;
result.status_codes = HashMap::from([(200, 100)]);
result.errors.clear();
let output = render_human(&result);
assert!(output.contains("TARN BENCH"));
assert!(output.contains("100 total"));
assert!(output.contains("66.7 req/s"));
assert!(output.contains("p95"));
assert!(output.contains("p99"));
assert!(output.contains("body-read"));
}
#[test]
fn render_csv_output() {
let csv = render_csv(&sample_bench_result());
assert!(csv.contains("throughput_rps"));
assert!(csv.contains("http://localhost"));
}
#[test]
fn evaluate_gates_reports_failures() {
let mut result = sample_bench_result();
result.throughput_rps = 15.0;
result.error_rate = 12.0;
result.latency.p95_ms = 80;
let gates = evaluate_gates(
&result,
&BenchThresholds {
min_throughput_rps: Some(20.0),
max_error_rate: Some(5.0),
max_p95_ms: Some(50),
max_p99_ms: None,
},
);
assert_eq!(gates.len(), 3);
assert!(gates.iter().all(|gate| !gate.passed));
}
}