strest 0.1.10

Blazing-fast async HTTP load tester in Rust - lock-free design, real-time stats, distributed runs, and optional chart exports for high-load API testing.
Documentation
mod support_single;

use std::fs;
use std::path::PathBuf;

use tempfile::tempdir;

use support_single::run_strest;
use support_single::spawn_http_server_or_skip;

fn prep_paths() -> Result<(tempfile::TempDir, String, String), String> {
    let dir = tempdir().map_err(|err| format!("tempdir failed: {}", err))?;
    let charts = dir.path().join("charts");
    let tmp_dir_path = dir.path().join("tmp");
    fs::create_dir_all(&charts).map_err(|err| format!("create charts dir failed: {}", err))?;
    fs::create_dir_all(&tmp_dir_path).map_err(|err| format!("create tmp dir failed: {}", err))?;
    Ok((
        dir,
        charts.to_string_lossy().into_owned(),
        tmp_dir_path.to_string_lossy().into_owned(),
    ))
}

#[test]
fn e2e_single_cli_basic() -> Result<(), String> {
    let Some((url, _server)) = spawn_http_server_or_skip()? else {
        return Ok(());
    };
    let (_dir, charts_path, tmp_path) = prep_paths()?;

    let args = vec![
        "-u".to_owned(),
        url,
        "-t".to_owned(),
        "2".to_owned(),
        "--no-tui".to_owned(),
        "--summary".to_owned(),
        "--no-charts".to_owned(),
        "--max-tasks".to_owned(),
        "10".to_owned(),
        "--rate".to_owned(),
        "20".to_owned(),
        "--spawn-rate".to_owned(),
        "1".to_owned(),
        "--spawn-interval".to_owned(),
        "100".to_owned(),
        "--tmp-path".to_owned(),
        tmp_path,
        "--charts-path".to_owned(),
        charts_path,
    ];

    let output = run_strest(args)?;
    if !output.status.success() {
        return Err(format!(
            "stdout: {}\nstderr: {}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    Ok(())
}

#[test]
fn e2e_single_exports() -> Result<(), String> {
    let Some((url, _server)) = spawn_http_server_or_skip()? else {
        return Ok(());
    };
    let (dir, charts_path, tmp_path) = prep_paths()?;

    let export_csv = dir.path().join("metrics.csv");
    let export_json = dir.path().join("metrics.json");
    let export_jsonl = dir.path().join("metrics.jsonl");

    let args = vec![
        "-u".to_owned(),
        url,
        "-t".to_owned(),
        "2".to_owned(),
        "--no-tui".to_owned(),
        "--summary".to_owned(),
        "--no-charts".to_owned(),
        "--metrics-max".to_owned(),
        "200".to_owned(),
        "--metrics-range".to_owned(),
        "0-1".to_owned(),
        "--export-csv".to_owned(),
        export_csv.to_string_lossy().into_owned(),
        "--export-json".to_owned(),
        export_json.to_string_lossy().into_owned(),
        "--export-jsonl".to_owned(),
        export_jsonl.to_string_lossy().into_owned(),
        "--tmp-path".to_owned(),
        tmp_path,
        "--charts-path".to_owned(),
        charts_path,
    ];

    let output = run_strest(args)?;
    if !output.status.success() {
        return Err(format!(
            "stdout: {}\nstderr: {}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    if !export_csv.exists() {
        return Err("Expected CSV export to exist.".to_owned());
    }
    if !export_json.exists() {
        return Err("Expected JSON export to exist.".to_owned());
    }
    if !export_jsonl.exists() {
        return Err("Expected JSONL export to exist.".to_owned());
    }
    let csv_meta =
        fs::metadata(&export_csv).map_err(|err| format!("csv metadata failed: {}", err))?;
    let json_meta =
        fs::metadata(&export_json).map_err(|err| format!("json metadata failed: {}", err))?;
    let jsonl_meta =
        fs::metadata(&export_jsonl).map_err(|err| format!("jsonl metadata failed: {}", err))?;
    if csv_meta.len() == 0 {
        return Err("CSV export was empty.".to_owned());
    }
    if json_meta.len() == 0 {
        return Err("JSON export was empty.".to_owned());
    }
    if jsonl_meta.len() == 0 {
        return Err("JSONL export was empty.".to_owned());
    }
    Ok(())
}

#[test]
fn e2e_single_config_toml_load_and_sinks() -> Result<(), String> {
    let Some((url, _server)) = spawn_http_server_or_skip()? else {
        return Ok(());
    };
    let (dir, charts_path, tmp_path) = prep_paths()?;

    let sink_path = dir.path().join("sink.prom");
    let config_path = dir.path().join("strest.toml");
    let config = format!(
        r#"url = "{url}"
duration = 2
no_ui = true
summary = true
no_charts = true
metrics_max = 200

[load]
rate = 10

[[load.stages]]
duration = "1s"
target = 20

[sinks]
update_interval_ms = 200

[sinks.prometheus]
path = "{sink}"
"#,
        url = url,
        sink = sink_path.to_string_lossy()
    );
    fs::write(&config_path, config).map_err(|err| format!("write config failed: {}", err))?;

    let args = vec![
        "--config".to_owned(),
        config_path.to_string_lossy().into_owned(),
        "--tmp-path".to_owned(),
        tmp_path,
        "--charts-path".to_owned(),
        charts_path,
    ];

    let output = run_strest(args)?;
    if !output.status.success() {
        return Err(format!(
            "stdout: {}\nstderr: {}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    if !sink_path.exists() {
        return Err("Expected sink output to exist.".to_owned());
    }
    let sink_meta =
        fs::metadata(&sink_path).map_err(|err| format!("sink metadata failed: {}", err))?;
    if sink_meta.len() == 0 {
        return Err("Sink output was empty.".to_owned());
    }
    Ok(())
}

#[test]
fn e2e_single_config_json_scenario() -> Result<(), String> {
    let Some((url, _server)) = spawn_http_server_or_skip()? else {
        return Ok(());
    };
    let (_dir, charts_path, tmp_path) = prep_paths()?;

    let config_path = PathBuf::from(tmp_path.clone()).join("strest.json");
    let config = serde_json::json!({
        "url": url,
        "duration": 2,
        "no_ui": true,
        "summary": true,
        "no_charts": true,
        "scenario": {
            "base_url": url,
            "steps": [
                { "method": "get", "path": "/health", "assert_status": 200 }
            ]
        }
    });
    let json_bytes = serde_json::to_vec_pretty(&config)
        .map_err(|err| format!("serialize config failed: {}", err))?;
    fs::write(&config_path, json_bytes).map_err(|err| format!("write config failed: {}", err))?;

    let args = vec![
        "--config".to_owned(),
        config_path.to_string_lossy().into_owned(),
        "--tmp-path".to_owned(),
        tmp_path,
        "--charts-path".to_owned(),
        charts_path,
    ];

    let output = run_strest(args)?;
    if !output.status.success() {
        return Err(format!(
            "stdout: {}\nstderr: {}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    Ok(())
}