wiremock-rs: HTTP Mocking in Rust Integration Tests

wiremock-rs: HTTP Mocking in Rust Integration Tests

wiremock-rs runs a real HTTP server in your test process. Your code under test makes actual HTTP calls — the mock server intercepts them, matches against your rules, and returns the responses you specify. No code changes required, no faking the HTTP client layer.

Key Takeaways

  • MockServer starts a real HTTP server on a random port — no port conflicts
  • Mock::given() chains matchers for method, path, headers, and body
  • ResponseTemplate sets status, headers, and response body
  • mount() registers a mock; mounted mocks are verified on drop
  • Times::exactly(n) asserts call count at the end of the test

Why a real mock server

Some approaches mock the HTTP client (replacing reqwest::Client with a fake). That works but it tests the wrong layer — your code uses a different code path than production.

wiremock-rs starts an actual TCP listener. Your real HTTP client connects to it over loopback. The same serialization, timeout, retry, and connection-pooling code runs in tests as in production.

Setup

[dev-dependencies]
wiremock = "0.6"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"

Basic mock

use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};

#[tokio::test]
async fn test_fetches_user() {
    // Start mock server on a random port
    let server = MockServer::start().await;

    // Register a mock
    Mock::given(method("GET"))
        .and(path("/users/1"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!({
                "id": 1,
                "name": "Alice"
            })))
        .mount(&server)
        .await;

    // Your code hits the mock server
    let url = format!("{}/users/1", server.uri());
    let response = reqwest::get(&url).await.unwrap();

    assert_eq!(response.status().as_u16(), 200);
    let body: serde_json::Value = response.json().await.unwrap();
    assert_eq!(body["name"], "Alice");
}
// When `server` drops, wiremock verifies all mounted mocks were called

server.uri() returns http://127.0.0.1:<port>. Pass this into your client or service under test wherever the base URL is configured.

Matching requests

Matchers compose with .and():

use wiremock::matchers::{method, path, header, body_json};

Mock::given(method("POST"))
    .and(path("/orders"))
    .and(header("Content-Type", "application/json"))
    .and(header("Authorization", "Bearer test-token"))
    .and(body_json(serde_json::json!({
        "item": "widget",
        "quantity": 3
    })))
    .respond_with(ResponseTemplate::new(201)
        .set_body_json(serde_json::json!({ "order_id": "abc123" })))
    .mount(&server)
    .await;

Available matchers:

Matcher Matches
method("GET") HTTP method
path("/foo") Exact path
path_regex("^/users/[0-9]+$") Regex path
header("name", "value") Exact header
header_exists("X-Request-Id") Header presence
query_param("page", "2") Query string
body_string("text") Exact body string
body_json(value) JSON body (exact match)
body_partial_json(value) JSON body (subset match)

Verifying call counts

By default, wiremock expects each mock to be called at least once. Use .expect() to be explicit:

use wiremock::Times;

// Must be called exactly once
Mock::given(method("POST"))
    .and(path("/payments"))
    .respond_with(ResponseTemplate::new(200))
    .expect(1)
    .mount(&server)
    .await;

// Must be called 3 times
Mock::given(method("GET"))
    .and(path("/items"))
    .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
    .expect(3)
    .mount(&server)
    .await;

// Must not be called
Mock::given(method("DELETE"))
    .and(path("/data"))
    .respond_with(ResponseTemplate::new(204))
    .expect(0)
    .mount(&server)
    .await;

When the MockServer drops at the end of the test, it verifies all expectations. Mismatches become test failures with a message that shows what was expected vs. what actually happened.

Simulating errors and slow responses

Test your client's error handling and timeouts:

use std::time::Duration;

// Server error
Mock::given(method("GET"))
    .and(path("/flaky"))
    .respond_with(ResponseTemplate::new(503)
        .set_body_string("Service Unavailable"))
    .mount(&server)
    .await;

// Slow response (useful for testing timeouts)
Mock::given(method("GET"))
    .and(path("/slow"))
    .respond_with(ResponseTemplate::new(200)
        .set_delay(Duration::from_secs(5)))
    .mount(&server)
    .await;

// Network-level error: use MockServer::builder().start_mock_error() for connection drops

Stateful sequences

Use .up_to_n_times() to return different responses on repeated calls — simulating retry scenarios:

// First call fails
Mock::given(method("GET"))
    .and(path("/resource"))
    .respond_with(ResponseTemplate::new(500))
    .up_to_n_times(2)
    .mount(&server)
    .await;

// Subsequent calls succeed
Mock::given(method("GET"))
    .and(path("/resource"))
    .respond_with(ResponseTemplate::new(200)
        .set_body_json(json!({ "data": "ok" })))
    .mount(&server)
    .await;

Mocks are matched in registration order. The first-registered mock is tried first; when it's exhausted (.up_to_n_times hit), the next one takes over.

Testing a real service client

Here's a complete example testing an API client that wraps an external service:

struct WeatherClient {
    base_url: String,
    client: reqwest::Client,
}

impl WeatherClient {
    fn new(base_url: &str) -> Self {
        Self {
            base_url: base_url.to_string(),
            client: reqwest::Client::new(),
        }
    }

    async fn get_temperature(&self, city: &str) -> anyhow::Result<f32> {
        let url = format!("{}/weather?city={}", self.base_url, city);
        let response = self.client.get(&url).send().await?;
        
        if !response.status().is_success() {
            anyhow::bail!("API error: {}", response.status());
        }

        let data: serde_json::Value = response.json().await?;
        Ok(data["temperature"].as_f64().unwrap_or(0.0) as f32)
    }
}

#[tokio::test]
async fn test_get_temperature_success() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/weather"))
        .and(query_param("city", "Berlin"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(json!({ "temperature": 18.5, "unit": "celsius" })))
        .expect(1)
        .mount(&server)
        .await;

    let client = WeatherClient::new(&server.uri());
    let temp = client.get_temperature("Berlin").await.unwrap();

    assert!((temp - 18.5).abs() < 0.01);
}

#[tokio::test]
async fn test_get_temperature_api_error() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/weather"))
        .respond_with(ResponseTemplate::new(503))
        .mount(&server)
        .await;

    let client = WeatherClient::new(&server.uri());
    let result = client.get_temperature("London").await;

    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("503"));
}

Inspecting received requests

When a test fails and you want to see what requests actually arrived, use server.received_requests():

// After running your code...
let requests = server.received_requests().await.unwrap();
for req in &requests {
    println!("Received: {} {}", req.method, req.url);
    println!("Body: {:?}", std::str::from_utf8(&req.body));
}

This is invaluable for debugging why a mock didn't match — you can see exactly what your client sent.

Integration with test fixtures

For tests that share the same mock setup, extract server initialization:

async fn setup_api_server() -> MockServer {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/health"))
        .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
        .mount(&server)
        .await;

    server
}

#[tokio::test]
async fn test_health_check() {
    let server = setup_api_server().await;
    let client = MyClient::new(&server.uri());
    assert!(client.is_healthy().await);
}

Summary

wiremock-rs gives Rust tests a real HTTP mock server with minimal setup. Your production HTTP client runs unchanged — it connects to the mock over loopback just as it would connect to a real API. Request matching is composable, response templating handles JSON and delays, and automatic expectation verification on drop catches missing or extra calls. It's the right tool for any Rust service that makes outbound HTTP calls.

Read more