Rust End-to-End API Testing: cargo-nextest, wiremock-rs, and httpmock

Rust End-to-End API Testing: cargo-nextest, wiremock-rs, and httpmock

End-to-end API testing in Rust has two distinct challenges: running tests fast enough that developers actually run them, and isolating your system from external HTTP dependencies without rewriting your production code. cargo-nextest solves the first problem — it runs tests in parallel processes with better output than cargo test. wiremock-rs and httpmock solve the second — they spin up real HTTP mock servers that your code talks to exactly as it would talk to a real service.

This post covers both tools in depth, with realistic patterns for testing API clients, verifying call counts, asserting request shapes, and integrating everything into CI.

cargo-nextest: Faster, Better Test Execution

The default cargo test harness runs test binaries sequentially and has limited output control. cargo-nextest is a modern replacement that brings process-level isolation, parallel execution, better progress reporting, and retry policies for flaky tests.

Installation and Basic Use

cargo install cargo-nextest

# Run all tests
cargo nextest run

<span class="hljs-comment"># Run tests matching a pattern
cargo nextest run -- api_client

<span class="hljs-comment"># Run with increased parallelism
cargo nextest run --test-threads 16

<span class="hljs-comment"># Show output for failing tests only (default)
cargo nextest run

<span class="hljs-comment"># Show output for all tests
cargo nextest run --no-capture

Configuring nextest.toml

nextest.toml (or .config/nextest.toml) controls execution behavior per-test-group:

# .config/nextest.toml
[profile.default]
# Number of threads for running tests
test-threads = "num-cpus"

# Retry flaky tests automatically
retries = 2

# Slow test threshold
slow-timeout = { period = "10s" }

# Tests that take longer than this are reported as failures
fail-fast = false

[profile.ci]
# In CI, fail fast to get signal quickly
fail-fast = true
retries = 1
test-threads = 4

# Slower timeout for CI (network calls, container startup)
slow-timeout = { period = "30s", terminate-after = 2 }

[[profile.default.overrides]]
# Tests tagged with "integration" get more time
filter = "test(integration)"
slow-timeout = { period = "60s" }
retries = 0

[[profile.default.overrides]]
# Tests tagged with "unit" run with tight timeouts
filter = "test(unit)"
slow-timeout = { period = "5s" }

Run with a specific profile in CI:

cargo nextest run --profile ci

Filtering Tests

nextest uses a custom filter expression language:

# Run only integration tests
cargo nextest run -E <span class="hljs-string">'test(integration)'

<span class="hljs-comment"># Run tests in a specific module
cargo nextest run -E <span class="hljs-string">'test(api_client::)'

<span class="hljs-comment"># Exclude slow tests
cargo nextest run -E <span class="hljs-string">'not test(slow)'

<span class="hljs-comment"># Combine filters
cargo nextest run -E <span class="hljs-string">'test(integration) and not test(database)'

CI Integration

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Install cargo-nextest
        uses: taiki-e/install-action@nextest

      - name: Run tests
        run: cargo nextest run --profile ci

      - name: Upload test results (JUnit XML)
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: target/nextest/ci/junit.xml

Generate JUnit XML for test result dashboards:

cargo nextest run --profile ci --message-format libtest-json \
  | cargo-nextest show-progress \
  > target/nextest/ci/junit.xml

wiremock-rs: Real HTTP Mock Servers

wiremock-rs starts an actual HTTP server on a random port. Your code makes real HTTP requests against it — exactly as it would against a real service. No mocking of HTTP clients, no patching of function pointers.

[dev-dependencies]
wiremock = "0.6"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

A Realistic API Client to Test

// src/github_client.rs
use reqwest::Client;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Repository {
    pub id: u64,
    pub name: String,
    pub full_name: String,
    pub private: bool,
    pub stargazers_count: u32,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CreateIssue {
    pub title: String,
    pub body: Option<String>,
    pub labels: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Issue {
    pub id: u64,
    pub number: u32,
    pub title: String,
    pub state: String,
}

#[derive(Debug, thiserror::Error)]
pub enum GitHubError {
    #[error("not found")]
    NotFound,
    #[error("unauthorized")]
    Unauthorized,
    #[error("rate limited")]
    RateLimited,
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),
}

pub struct GitHubClient {
    client: Client,
    base_url: String,
    token: String,
}

impl GitHubClient {
    pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
        Self {
            client: Client::new(),
            base_url: base_url.into(),
            token: token.into(),
        }
    }

    pub async fn get_repo(&self, owner: &str, repo: &str) -> Result<Repository, GitHubError> {
        let response = self.client
            .get(format!("{}/repos/{}/{}", self.base_url, owner, repo))
            .bearer_auth(&self.token)
            .send()
            .await?;

        match response.status().as_u16() {
            200 => Ok(response.json::<Repository>().await?),
            401 => Err(GitHubError::Unauthorized),
            404 => Err(GitHubError::NotFound),
            429 => Err(GitHubError::RateLimited),
            _ => Err(GitHubError::Http(
                response.error_for_status().unwrap_err()
            )),
        }
    }

    pub async fn create_issue(
        &self,
        owner: &str,
        repo: &str,
        issue: CreateIssue,
    ) -> Result<Issue, GitHubError> {
        let response = self.client
            .post(format!("{}/repos/{}/{}/issues", self.base_url, owner, repo))
            .bearer_auth(&self.token)
            .json(&issue)
            .send()
            .await?;

        match response.status().as_u16() {
            201 => Ok(response.json::<Issue>().await?),
            401 => Err(GitHubError::Unauthorized),
            404 => Err(GitHubError::NotFound),
            _ => Err(GitHubError::Http(response.error_for_status().unwrap_err())),
        }
    }

    pub async fn list_repos(&self, org: &str) -> Result<Vec<Repository>, GitHubError> {
        let response = self.client
            .get(format!("{}/orgs/{}/repos", self.base_url, org))
            .bearer_auth(&self.token)
            .query(&[("per_page", "100")])
            .send()
            .await?;

        match response.status().as_u16() {
            200 => Ok(response.json::<Vec<Repository>>().await?),
            401 => Err(GitHubError::Unauthorized),
            _ => Err(GitHubError::Http(response.error_for_status().unwrap_err())),
        }
    }
}

wiremock-rs Test Patterns

#[cfg(test)]
mod tests {
    use super::*;
    use wiremock::{MockServer, Mock, ResponseTemplate};
    use wiremock::matchers::{method, path, header, query_param, body_json};
    use serde_json::json;

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

        Mock::given(method("GET"))
            .and(path("/repos/rust-lang/rust"))
            .and(header("Authorization", "Bearer test-token"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "id": 724712,
                "name": "rust",
                "full_name": "rust-lang/rust",
                "private": false,
                "stargazers_count": 98000
            })))
            .mount(&server)
            .await;

        let client = GitHubClient::new(server.uri(), "test-token");
        let repo = client.get_repo("rust-lang", "rust").await.unwrap();

        assert_eq!(repo.id, 724712);
        assert_eq!(repo.name, "rust");
        assert_eq!(repo.full_name, "rust-lang/rust");
        assert!(!repo.private);
        assert_eq!(repo.stargazers_count, 98000);
    }

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

        Mock::given(method("GET"))
            .and(path("/repos/nonexistent/repo"))
            .respond_with(ResponseTemplate::new(404).set_body_json(json!({
                "message": "Not Found"
            })))
            .mount(&server)
            .await;

        let client = GitHubClient::new(server.uri(), "test-token");
        let result = client.get_repo("nonexistent", "repo").await;

        assert!(matches!(result, Err(GitHubError::NotFound)));
    }

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

        Mock::given(method("GET"))
            .and(path("/repos/any/repo"))
            .respond_with(ResponseTemplate::new(401).set_body_json(json!({
                "message": "Bad credentials"
            })))
            .mount(&server)
            .await;

        let client = GitHubClient::new(server.uri(), "bad-token");
        let result = client.get_repo("any", "repo").await;

        assert!(matches!(result, Err(GitHubError::Unauthorized)));
    }
}

Verifying Request Bodies and Call Counts

wiremock-rs lets you assert not just that requests were received, but that they had the expected shape and were called the expected number of times:

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

    let mock = Mock::given(method("POST"))
        .and(path("/repos/myorg/myrepo/issues"))
        .and(header("Content-Type", "application/json"))
        .and(body_json(json!({
            "title": "Bug report",
            "body": "Something is broken",
            "labels": ["bug", "priority:high"]
        })))
        .respond_with(ResponseTemplate::new(201).set_body_json(json!({
            "id": 1,
            "number": 42,
            "title": "Bug report",
            "state": "open"
        })))
        .expect(1)  // Must be called exactly once
        .mount_as_scoped(&server)
        .await;

    let client = GitHubClient::new(server.uri(), "test-token");
    let issue = client.create_issue(
        "myorg",
        "myrepo",
        CreateIssue {
            title: "Bug report".into(),
            body: Some("Something is broken".into()),
            labels: vec!["bug".into(), "priority:high".into()],
        },
    )
    .await
    .unwrap();

    assert_eq!(issue.number, 42);
    assert_eq!(issue.state, "open");

    // mock is dropped here — wiremock asserts the .expect(1) constraint
    drop(mock);
}

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

    Mock::given(method("GET"))
        .and(path("/orgs/myorg/repos"))
        .and(query_param("per_page", "100"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
        .expect(1)
        .mount(&server)
        .await;

    let client = GitHubClient::new(server.uri(), "test-token");
    let repos = client.list_repos("myorg").await.unwrap();
    assert!(repos.is_empty());

    server.verify().await;
}

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

    Mock::given(method("GET"))
        .respond_with(
            ResponseTemplate::new(429)
                .insert_header("Retry-After", "60")
                .set_body_json(json!({ "message": "API rate limit exceeded" }))
        )
        .mount(&server)
        .await;

    let client = GitHubClient::new(server.uri(), "test-token");
    let result = client.get_repo("any", "repo").await;

    assert!(matches!(result, Err(GitHubError::RateLimited)));
}

Response Templating and Sequential Responses

For testing retry logic or pagination, configure mocks to return different responses on successive calls:

use wiremock::MockBuilder;

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

    // First call returns 503, second returns 200
    Mock::given(method("GET"))
        .and(path("/repos/org/repo"))
        .respond_with(ResponseTemplate::new(503))
        .up_to_n_times(1)  // Only respond this way once
        .mount(&server)
        .await;

    Mock::given(method("GET"))
        .and(path("/repos/org/repo"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "id": 1, "name": "repo", "full_name": "org/repo",
            "private": false, "stargazers_count": 100
        })))
        .mount(&server)
        .await;

    // Assuming your client has retry logic:
    let client = GitHubClient::new(server.uri(), "token");
    // First attempt fails with 503 (but client retries)
    // Second attempt succeeds
    // This test structure works when you add retry middleware
}

httpmock: A Simpler Alternative

httpmock provides similar functionality with a slightly different API that some find more readable for simple cases:

[dev-dependencies]
httpmock = "0.7"
use httpmock::prelude::*;
use serde_json::json;

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

    let mock = server.mock(|when, then| {
        when.method(GET)
            .path("/repos/owner/repo")
            .header("Authorization", "Bearer my-token");

        then.status(200)
            .header("Content-Type", "application/json")
            .json_body(json!({
                "id": 100,
                "name": "repo",
                "full_name": "owner/repo",
                "private": false,
                "stargazers_count": 500
            }));
    });

    let client = GitHubClient::new(server.base_url(), "my-token");
    let repo = client.get_repo("owner", "repo").await.unwrap();

    assert_eq!(repo.id, 100);
    assert_eq!(repo.stargazers_count, 500);

    // Verify the mock was called exactly once
    mock.assert();
}

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

    let mock = server.mock(|when, then| {
        when.method(POST)
            .path("/repos/owner/repo/issues")
            .json_body_partial(json!({
                "title": "Test Issue"
            }));

        then.status(201)
            .json_body(json!({
                "id": 1,
                "number": 7,
                "title": "Test Issue",
                "state": "open"
            }));
    });

    let client = GitHubClient::new(server.base_url(), "token");
    let issue = client.create_issue(
        "owner",
        "repo",
        CreateIssue {
            title: "Test Issue".into(),
            body: None,
            labels: vec![],
        },
    )
    .await
    .unwrap();

    assert_eq!(issue.number, 7);
    mock.assert_hits(1);
}

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

    server.mock(|when, then| {
        when.method(GET).path_matches(Regex::new("/repos/.*").unwrap());
        then.status(404)
            .json_body(json!({ "message": "Not Found" }));
    });

    let client = GitHubClient::new(server.base_url(), "token");
    let result = client.get_repo("ghost", "missing").await;

    assert!(matches!(result, Err(GitHubError::NotFound)));
}

Choosing Between wiremock-rs and httpmock

Both libraries start real HTTP servers and support request matching — the differences are ergonomic:

Feature wiremock-rs httpmock
API style Builder with .given() matchers Closure with when/then
Async support Native async Sync API (spawn internally)
Request recording Yes Yes
Sequential responses up_to_n_times External recorder
Body matching JSON, partial, regex JSON, partial, body functions

Use wiremock-rs when you need fine-grained async control, complex matcher composition, or scoped mock lifetimes. Use httpmock when you want simpler, synchronous-feeling test code and the closure style fits your team.

Complete End-to-End Test Pattern

A full E2E test for a service that calls an external API:

#[tokio::test]
async fn full_pipeline_fetches_and_stores_repo_stats() {
    // Start mock for the external GitHub API
    let github_server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/repos/rust-lang/rust"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "id": 724712,
            "name": "rust",
            "full_name": "rust-lang/rust",
            "private": false,
            "stargazers_count": 98000
        })))
        .expect(1)
        .mount(&github_server)
        .await;

    // Initialize your service with the mock URL instead of the real GitHub URL
    let service = RepoStatsService::new(
        GitHubClient::new(github_server.uri(), "test-token"),
        InMemoryStatsStore::new(),
    );

    // Run the pipeline
    service.sync_repo_stats("rust-lang", "rust").await.unwrap();

    // Verify the result was stored correctly
    let stats = service.get_stats("rust-lang", "rust").await.unwrap();
    assert_eq!(stats.star_count, 98000);
    assert_eq!(stats.repo_name, "rust");

    // Verify the GitHub API was called exactly once
    github_server.verify().await;
}

Continuous Testing with HelpMeTest

Fast tests via cargo-nextest and clean mock isolation via wiremock-rs give you a test suite that runs reliably locally. The missing piece is knowing when those tests break in your CI environment — different network conditions, different container startup times, different parallelism settings.

HelpMeTest provides continuous API test monitoring that works alongside your nextest setup. Run your suite on every commit with automatic flakiness tracking, historical pass/fail trends, and alerts when a test that was green starts failing. For services that depend on external APIs — even when those APIs are mocked in tests — continuous execution catches the regressions that only appear under load or in specific environments.

The combination of cargo-nextest for fast parallel execution, wiremock-rs for realistic HTTP isolation, and continuous monitoring gives Rust API projects a production-grade testing foundation that doesn't slow development down.

Read more