Skip to content

Rust Library (in-process testing)

Rust projects can embed VidaiMock directly in their integration tests. Instead of downloading a binary, picking a free port, spawning a process, polling /health, and tearing it all down again, you get a server your test owns.

API stability

The library API is new in 0.3.0. While the crate is 0.x it may change between minor versions — pin a version you have tested against.

Install

[dev-dependencies]
vidaimock = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Basic usage

use vidaimock::MockServer;

#[tokio::test]
async fn agent_calls_openai() -> Result<(), Box<dyn std::error::Error>> {
    let server = MockServer::builder()
        .bind("127.0.0.1:0")     // ephemeral port
        .start()
        .await?;

    // Point the system under test at the mock.
    let base_url = server.base_url();   // e.g. http://127.0.0.1:54321
    std::env::set_var("OPENAI_BASE_URL", format!("{base_url}/v1"));

    // ... exercise your code, then assert ...

    server.shutdown().await?;
    Ok(())
}

start() returns once the socket is bound, so the server is ready the moment it resolves — no health polling needed.

Why port 0

Binding 127.0.0.1:0 lets the OS pick a free port, which is what makes parallel tests safe. Read the real address back with addr() or base_url():

let server = MockServer::builder().bind("127.0.0.1:0").start().await?;
println!("listening on {}", server.addr());   // 127.0.0.1:54321

Hardcoding a port makes tests collide the moment two run at once, or when a developer already has something on that port.

Parallel tests

Instances share no global state, so any number can run concurrently — which is exactly how cargo test runs your tests by default:

#[tokio::test]
async fn test_one() {
    let server = MockServer::builder().bind("127.0.0.1:0").start().await.unwrap();
    // ... independent of every other test ...
    server.shutdown().await.unwrap();
}

#[tokio::test]
async fn test_two() {
    let server = MockServer::builder().bind("127.0.0.1:0").start().await.unwrap();
    // ... runs at the same time, its own port, its own state ...
    server.shutdown().await.unwrap();
}

Shutdown

Two options:

server.shutdown().await?;   // stops and waits for termination
{
    let server = MockServer::builder().bind("127.0.0.1:0").start().await?;
    // ...
}   // dropped here — best-effort stop, not awaited

Prefer shutdown() when the test needs the port released before it continues. Drop is a safety net for panics and early returns, not the primary path.

Custom providers and templates

The bundled providers and templates are embedded in the crate, so the default setup needs no files on disk. To override them, point at a config directory:

let server = MockServer::builder()
    .bind("127.0.0.1:0")
    .config_dir("tests/fixtures/providers")
    .start()
    .await?;

To use only your own definitions and disable the embedded defaults (equivalent to --isolated on the CLI):

let server = MockServer::builder()
    .bind("127.0.0.1:0")
    .config_dir("tests/fixtures/providers")
    .isolated(true)
    .start()
    .await?;

See Overriding bundled defaults for how the two layers interact.

Simulating latency

Add an artificial delay to every response — useful for mimicking real provider latency, and for pushing a client past its own timeout to check that its failure handling actually works:

let server = MockServer::builder()
    .bind("127.0.0.1:0")
    .mode("realistic")
    .latency_ms(40)          // ~40ms per response
    .start()
    .await?;

To fail a single request without restarting the server, use the X-Vidai-Latency header:

client.post(format!("{}/v1/chat/completions", server.base_url()))
    .header("X-Vidai-Latency", "2500")   // past a 1s client timeout
    .json(&body)
    .send()
    .await?;

This is how gateway and router projects rehearse their failure gates: point the system under test at the mock, raise the latency past its timeout, and confirm it fails closed rather than silently passing.

Error handling

Startup problems are returned, never fatal — a library must not terminate its host process:

match MockServer::builder().bind("127.0.0.1:8100").start().await {
    Ok(server) => { /* ... */ }
    Err(vidaimock::Error::Bind { addr, .. }) => {
        eprintln!("port busy: {addr}");
    }
    Err(e) => eprintln!("failed to start: {e}"),
}

vidaimock::Error is #[non_exhaustive], so match on it needs a catch-all arm. That lets new variants be added without breaking your code.

Logging

The library deliberately does not install a tracing subscriber — that is process-global and would fight with your application's own setup. To see the server's logs, install your own:

tracing_subscriber::fmt().with_env_filter("vidaimock=debug").init();

Prometheus metrics are likewise not installed by the library; the /metrics endpoint is a feature of the binary.

When to use the binary instead

Use Reach for
Rust integration tests This library
Non-Rust test suites Docker or the binary
Shared instance across a team or CI job Docker
Manual exploration with curl The binary
Language-agnostic CI service container CI/CD Integration