When unit tests aren't enough
You've written unit tests for your request handlers. They pass. You deploy. The API returns a 500 error because the router couldn't find the path, or the JSON deserialization choked on a field name, or a middleware panicked on a missing header. Unit tests check functions in isolation. They don't check the whole stack. You need to test the API as a client would see it.
Integration tests verify that your routes, middleware, serialization, and database interactions work together. They treat your library as a black box and exercise the public API. This catches bugs that unit tests miss, like configuration errors, type mismatches in request bodies, and lifecycle issues.
The tests/ directory is a separate crate
Rust treats every file in the tests/ directory at the crate root as a separate crate. This is the defining feature of integration tests. When you write a test in tests/api.rs, the compiler builds a new crate that links against your library. That test crate can only access items you have exported. It cannot see private fields or internal helper functions.
This separation is intentional. It forces you to use your own public API. If your public API is awkward, the test will tell you. If you rename a public function, the test breaks. If you make a field private, the test breaks. The compiler enforces the boundary between your implementation and your interface.
Unit tests live in src/ alongside the code they test. They can access private items. Integration tests live in tests/. They cannot. Use tests/ for behavior verification. Use src/ for implementation verification.
Minimal integration test
Create a tests/ directory at the root of your crate, next to src/ and Cargo.toml. Add a file like tests/api_integration.rs. The file is a standalone crate, so it needs to import your library explicitly.
// tests/api_integration.rs
use my_web_app::App;
/// Verifies that the root endpoint returns 200 and valid JSON.
#[test]
fn test_root_returns_ok() {
// App::new() initializes the router and any default configuration.
let app = App::new();
// get() simulates an HTTP request through the full application stack.
let response = app.get("/");
// 200 OK confirms the route exists and the handler succeeded.
assert_eq!(response.status, 200);
// The content type must be JSON for API consumers to parse the body.
assert_eq!(response.content_type, "application/json");
}
Run the test with cargo test. Cargo compiles tests/api_integration.rs as a separate crate, links it against my_web_app, and executes the test. You can run a specific file with cargo test --test api_integration.
Integration tests force you to use your own public API. If the public API is awkward, the test will tell you.
Realistic async web API test
Most modern Rust web frameworks are async. Your integration tests need an async runtime. Add #[tokio::test] to your test functions. This attribute sets up a Tokio runtime for the test, allowing you to use async and await.
// tests/user_api.rs
use my_web_app::{App, User};
use serde_json::json;
/// Verifies that creating a user returns 201 and includes an ID.
#[tokio::test]
async fn test_create_user_returns_201() {
// App::new() initializes the router and in-memory database.
let app = App::new().await;
// json! creates a Value from the literal, avoiding manual serialization errors.
let body = json!({
"name": "Alice",
"email": "alice@example.com"
});
// post() sends the request through the full middleware stack.
let response = app.post("/users", body).await;
// 201 Created confirms the resource was successfully added.
assert_eq!(response.status, 201);
// The response body must contain the generated ID to prove persistence.
assert!(response.json.get("id").is_some());
}
/// Verifies that duplicate emails are rejected with 409 Conflict.
#[tokio::test]
async fn test_duplicate_email_returns_conflict() {
let app = App::new().await;
// Create the first user to populate the database.
let body = json!({ "name": "Alice", "email": "alice@example.com" });
let _first = app.post("/users", body).await;
// Attempt to create a second user with the same email.
let response = app.post("/users", body.clone()).await;
// 409 Conflict indicates the server understood the request but couldn't process it due to a conflict.
assert_eq!(response.status, 409);
}
The serde_json::json! macro is the community standard for building test fixtures. It parses the literal at compile time and returns a serde_json::Value. This avoids string escaping errors and gives you type-safe access to the structure. Don't write raw JSON strings in tests. Use json!.
Dev-dependencies keep production lean
Integration tests often need dependencies that your production code doesn't use. You might need a test harness, a mock server, or a heavy async runtime. Put these in dev-dependencies in Cargo.toml. Cargo only compiles dev-dependencies when you run cargo test. They never end up in your release binary.
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
serde_json = "1"
This convention keeps your production artifact small and reduces compile times for end users. If a dependency is only used by tests, it belongs in dev-dependencies. If your library code uses it, it belongs in dependencies.
Pitfalls and compiler errors
Integration tests expose different failure modes than unit tests. The compiler errors you see often relate to the crate boundary.
If you try to access a private field from tests/, the compiler rejects you with E0616 (field is private). This is the point. Your integration test lives outside your crate. It can only see what you export. Treat E0616 as a feature. It's the compiler guarding your encapsulation.
Tests run in parallel by default. cargo test spawns multiple threads to execute tests concurrently. If your tests share mutable state, like a global database or a static variable, you'll get race conditions. The tests will pass sometimes and fail other times. Use isolated state for each test. Create a fresh App instance per test. Use in-memory databases that don't persist between tests. If you must share state, use #[serial] from the serial_test crate to force sequential execution, but prefer isolation.
Slow tests block feedback. Integration tests are slower than unit tests because they compile separate crates and exercise more code. Keep tests fast by using in-memory storage for databases and avoiding network calls. If you need a real database, use SQLite in-memory mode or testcontainers to spin up a Docker container on demand. Don't rely on a shared development database. Tests should be self-contained.
Decision: when to use integration tests
Use integration tests when you need to verify the full request lifecycle, including routing, middleware, and serialization. Use integration tests when you're validating interactions between multiple modules, like a handler calling a service that calls a repository. Use integration tests when you want to catch configuration errors and type mismatches that unit tests miss.
Use unit tests when you're validating business logic inside a single function and don't care about HTTP details. Use unit tests for pure functions, parsers, and algorithms. Use unit tests to achieve high coverage of edge cases that are expensive to trigger via the API.
Use doc tests when you want to prove that a code example in your documentation actually compiles and runs. Doc tests are lightweight integration tests for your examples. They keep your docs honest.
Use property-based tests when the input space is too large to enumerate manually and you need to find edge cases automatically. Property tests generate random inputs and check invariants. They complement integration tests by exploring paths you didn't think to write.
Write integration tests for the happy path and the boundaries. Write unit tests for the messy logic inside.