People love to repeat that in Rust "if it compiles, it works". The compiler does kill a whole class of bugs, but it doesn't check your logic. A wrong discount calculation compiles just fine. So does a mixed-up header in a request to a payment API. You still need tests, and writing them in Rust is easy enough: the runner is built into the toolchain, no jest/pytest to install.
A quick look at the basics first, then the painful part: testing code that talks HTTP.
What the language gives you
A minimal test with zero dependencies:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discount_is_applied() {
assert_eq!(apply_discount(100, 10), 90);
}
}
cargo test and you're done. Unit tests live next to the code and can see private functions. Integration tests go into the tests/ directory and treat the crate as an external consumer, public API only.
And then there are doc tests. Code examples in your docs get compiled and executed on every run. An outdated example in the README simply won't pass CI. Small thing, but it keeps you honest.
As for third-party stuff, the usual suspects in dev-dependencies are pretty_assertions (a diff instead of a wall of text when comparing big structs) and proptest - property-based testing, where you describe an invariant and the library generates hundreds of inputs and shrinks the counterexample for you. Some people prefer quickcheck, it's older and simpler. For snapshots there's insta. Async tests get wrapped in #[tokio::test].
One thing you don't get out of the box at all is parametrized tests. rstest fills that gap - one test runs against several sets of inputs via #[case], and you get pytest-style fixtures on top:
#[rstest]
#[case(100, 10, 90)]
#[case(100, 0, 100)]
#[case(0, 50, 0)]
fn discount(#[case] price: u64, #[case] pct: u64, #[case] expected: u64) {
assert_eq!(apply_discount(price, pct), expected);
}
Every #[case] becomes a separate test with its own name in the output. Without it you'd be either copy-pasting the function or writing a loop that dies with a useless message somewhere on the third iteration.
All of this works great until your function calls reqwest::get(...).
Code that talks to the outside world
A production service rarely lives in a vacuum: a call to a payment provider here, a request to a neighboring microservice there. Running tests over the real network means depending on someone else's staging and its rate limits. Such tests get flaky, flaky tests get ignored, you know how it ends.
There are two approaches, and they're not interchangeable.
Abstract it away and mock the trait
Put the HTTP interaction behind a trait, swap in a fake for tests:
#[cfg_attr(test, mockall::automock)]
trait PaymentGateway {
fn charge(&self, amount: u64) -> Result<Receipt, PayError>;
}
mockall generates a MockPaymentGateway with configurable expectations: which method gets called, with what arguments and how many times. These tests are fast and depend on nothing external.
The catch is that you're mocking your own abstraction, not the actual interaction. A trait mock will confirm you called charge(100). Whether a correct POST with the right Content-Type actually went over the wire, or what happens when the server answers with a 503 - none of that is covered.
Spin up a real server
The second way is to run a local HTTP server right inside the test and have it pretend to be the external API. Client code doesn't change, all you need is a configurable base URL. The request goes through the whole stack for real, down to TCP.
mockito is the veteran. The shortest path from zero to a working test:
let mut server = mockito::Server::new();
let mock = server.mock("GET", "/hello")
.with_status(200)
.with_body("world")
.create();
// ... hit server.url() with your client ...
mock.assert();
wiremock is async-first, inspired by the Java library of the same name. Composable matchers, fits naturally with tokio:
Mock::given(method("POST"))
.and(path("/orders"))
.respond_with(ResponseTemplate::new(201))
.expect(1)
.mount(&mock_server)
.await;
If your service runs on axum, you'll probably pick this one and won't regret it.
httpmock does all of the above plus a standalone mode: the server can run separately and be shared across tests, or even across languages in a polyglot project.
whyhttp is a recent one. It runs in a background thread with no async runtime, which is handy for sync tests, but the more interesting part is that it splits matchers into routing (when) and validation (should):
let server = whyhttp::Whyhttp::run();
server
.when().path("/orders").method("POST") // which request we serve
.should().body(r#"{"qty":1}"#) // what it must look like
.response().status(201);
Here's why the split matters. In most libraries the request body is part of the matching condition. Send {"qty":2} instead of {"qty":1} and the mock "isn't found", so the client gets a 404 and the test blows up somewhere in deserialization with a message that has nothing to do with the actual cause. With whyhttp that request still gets its 201 and the scenario runs to the end, while the body mismatch shows up in the final report telling you exactly which check failed. The report is printed when the server is dropped, so forgetting to call verify() and shipping an evergreen test just can't happen here.
Conceptually all four work the same (set up expectations, swap the URL), so migrating between them is cheap.
A third way: run the real dependency in a container
Sometimes you don't want to mock at all. A mock of S3 is always somebody's fantasy about how S3 behaves, and the subtle stuff like listing pagination or multipart uploads will be wrong in it. Same goes for databases, queues and really any sufficiently complex service.
This is where the testcontainers approach has been gaining traction: the test starts a docker container with the real dependency, runs against it and kills the container afterwards. In Rust that's the testcontainers crate plus testcontainers-modules with ready-made images:
use testcontainers_modules::{minio::MinIO, testcontainers::runners::AsyncRunner};
#[tokio::test]
async fn uploads_report_to_s3() {
let container = MinIO::default().start().await.unwrap();
let port = container.get_host_port_ipv4(9000).await.unwrap();
let client = s3_client(&format!("http://localhost:{port}"));
// from here on we're talking to a real S3-compatible store
}
Instead of an S3 mock you get a real MinIO. For a third-party API, grab its official docker image, or LocalStack if it's AWS. The price is speed: a container takes seconds to start, not milliseconds, and CI needs docker access. So containers don't replace HTTP mocks, they complement them: mocks for testing your client (those headers and retries), containers for when the behavior of the dependency itself matters.
By the way, don't confuse this with VS Code devcontainers - those solve a different problem, reproducible dev environments. Though they help with testing too: the same image for the whole team and CI means "works on my machine" stops being an argument.
Putting it together
Business logic gets plain unit tests plus property-based ones, no network, no mocks. The more logic you pull out into pure functions, the cheaper it is to cover.
Integration with external APIs goes through a local mock server. That's where you check serialization, headers, reactions to error statuses, retries. For an HTTP client this is the most valuable layer: it catches exactly the bugs that trait mocks miss.
For orchestration ("payment failed -> order marked as failed") trait mocks from mockall are enough, bytes on the wire don't matter there. And where the dependency's behavior is more complex than you'd care to mock, testcontainers it is.
Plus a couple of real e2e tests against actual staging, in a separate suite that doesn't block CI and whose flaky nature everyone has honestly agreed on.
Wrapping up
Testing HTTP code in Rust used to boil down to "hide everything behind traits and suffer". These days a mock server is one line away, and libraries compete on ergonomics rather than feature lists: how good the error reports are, whether you can even forget to verify.
The compiler still won't check that the JSON you send is the right one. But the test for that now takes a minute to write.
Top comments (0)