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 - property-based testing, where you describe an invariant and the library generates hundreds of inputs and shrinks the counterexample for you. Some people prefer . Async tests get wrapped in #[tokio::test].
One thing you don't get out of the box at all is parametrized tests. 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.
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.
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 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.
SOCIAL SHARE CARD GENERATOR