Testing in Rust: concepts, trade-offs and production lessons
Testing is often treated as a second-class citizen in many codebases, yet in Rust the testing framework is so deeply woven into the language that skipping it becomes harder than using it. After running production systems for years, we've learned that Rust's built-in testing capabilities—when used thoughtfully—catch entire categories of bugs before they reach your users. This guide walks through the concepts, trade-offs and production lessons we've gathered building e-commerce and SaaS platforms in Rust.
Unit tests—#[test] functions in a tests module
The simplest and most common test type in Rust lives right beside your code. A #[test] function is just a regular function marked with an attribute, compiled and run by cargo test. The beauty is that Rust enforces a convention: you write tests in a #[cfg(test)] module, which means the test code is only compiled when you explicitly run tests. This keeps your binary lean.
Consider a pricing service that calculates totals with tax. In production, getting this wrong costs money and trust. Here's how we'd test it:
pub fn total(base: u32, tax_rate: u32) -> u32 {
base + (base * tax_rate / 100)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_tax_correctly() {
assert_eq!(total(100, 10), 110);
}
#[test]
fn handles_zero_tax() {
assert_eq!(total(100, 0), 100);
}
#[test]
fn rounding_behavior_with_fractions() {
assert_eq!(total(99, 15), 113);
}
}Each test is independent. When you run cargo test, Rust compiles them separately and runs each one. If any fails, the test suite stops and reports the failure. The key trade-off: unit tests are fast and tightly coupled to implementation, but they don't catch integration issues. A tax calculation might be correct in isolation but wrong when combined with currency conversion or shipping logic.
In our experience, the sweet spot is one test file per module—or one #[cfg(test)] block per public function. Avoid the temptation to test every private helper; focus on public APIs and the boundaries where bugs hurt your business.
Integration tests—files in the tests/ folder
Integration tests live in a separate tests/ directory at the root of your project. Unlike unit tests, they import your crate as a library and test the public interface only. This mirrors how external consumers actually use your code.
Imagine you have a checkout service that orchestrates payment, shipping, and inventory. A unit test might verify that individual functions return the right values, but an integration test ensures the entire flow works together.
// tests/checkout_flow.rs
use mystore::checkout::{OrderRequest, process_order};
use mystore::storage::InMemoryCart;
#[test]
fn checkout_updates_inventory_and_confirms_payment() {
let cart = InMemoryCart::new();
cart.add_item("widget-1", 2);
let request = OrderRequest {
customer_id: "cust-123".to_string(),
items: vec![],
shipping_address: "123 Main St".to_string(),
};
let result = process_order(request);
assert!(result.is_ok());
let order = result.unwrap();
assert_eq!(order.total, 299);
assert_eq!(order.status, "confirmed");
}Integration tests are slower—they're compiled as separate binaries—but they're invaluable for catching API contract violations and orchestration bugs. In production systems handling real transactions, we've found that integration tests catch about 30% of bugs that unit tests miss. The trade-off is compile time and test runtime; we typically run them in CI pipelines rather than on every local save.
A practical lesson: write integration tests for critical paths—payment processing, user authentication, data persistence. Skip them for utilities and helpers. Your deployment pipeline has limited time; spend it wisely.
Async tests—#[tokio::test]
Most modern Rust applications are async. The standard #[test] macro doesn't know how to run async functions, so Rust's async ecosystem provides the #[tokio::test] macro (and similar from other runtimes like Actix). It sets up a runtime, executes your test, and tears it down.
// In Cargo.toml: [dev-dependencies] tokio = { version = "1", features = ["full"] }
#[tokio::test]
async fn fetch_order_from_database() {
let db = setup_test_db().await;
let order = db.get_order("order-456").await;
assert!(order.is_some());
let order = order.unwrap();
assert_eq!(order.total, 499);
}Without #[tokio::test], you'd have to manually create a runtime with tokio::runtime::Runtime::new(), which is verbose and error-prone. The macro handles it transparently. The trade-off: each async test spawns its own runtime, so they're slower than unit tests but necessary for any I/O-bound code—databases, HTTP clients, message queues.
In our ride-hailing platform, we have hundreds of async tests that hit a real PostgreSQL instance spun up in Docker for CI. The tests are slow (they take 5–10 seconds per test), but they catch subtle concurrency bugs—duplicate key errors, transaction deadlocks—that never appear in unit tests. Run async tests in CI, not on every keystroke locally.
Doc tests—examples in docs are tested too
Rust has a powerful feature many teams overlook: doc tests. Any code example in a documentation comment is compiled and run as a test. This keeps your README and API docs honest—if the example is outdated, it fails.
/// Calculates the total price including tax.
///
/// # Examples
///
/// ```
/// use mystore::pricing::total;
/// assert_eq!(total(100, 10), 110);
/// ```
pub fn total(base: u32, tax_rate: u32) -> u32 {
base + (base * tax_rate / 100)
}When you run cargo test --doc, Rust extracts that example, compiles it, and runs it. If the function signature changes and the docs don't, the test fails. We've found doc tests catch API regressions in about 20% of breaking changes in our libraries.
The trade-off is maintenance burden. Every example must be correct and complete. For complex setups—mocking a database connection, initializing fixtures—doc tests become unwieldy. Use them for simple, illustrative examples. For involved scenarios, stick to unit and integration tests.
One practical pattern: mark doc tests as ignored if they need external resources.
/// ```ignore
/// let db = connect_to_production_db();
/// ```
pub fn dangerous_operation() {}Common mistakes
Writing tests that pass non-deterministically. Tests that depend on timing, random seeds, or external service state will fail unpredictably. Use dependency injection to mock external dependencies. In our payment processing tests, we inject a fake payment gateway that always returns success; in staging, we use the real gateway.
Testing implementation instead of behavior. A test that asserts a private variable has a specific value will break when you refactor. Test public outputs. Test that process_order() returns an order with status "confirmed", not that an internal field was mutated.
Ignoring test organization. A single test file with 500 functions is hard to navigate. Group related tests with nested modules. Use descriptive names: test_checkout_with_invalid_address_fails is better than test_checkout_fail.
Forgetting that tests are code. Tests require maintenance. Duplicate setup logic, overly complex assertions, and brittle mocks accumulate. Refactor test code as ruthlessly as production code. We extract common setup into helper functions and use builder patterns for complex test objects.
Key takeaways
- Unit tests in
#[cfg(test)]modules are fast and cheap; use them for isolated logic and edge cases. - Integration tests in
tests/catch orchestration bugs; write them for critical paths like payments and authentication. - Async tests with
#[tokio::test]are necessary for I/O-bound code; run them in CI rather than locally on every change. - Doc tests keep examples in sync with code; use them sparingly for clear, simple illustrations.
- Test for behavior and outputs, not implementation details; mock external dependencies to keep tests deterministic.
- Treat test code with the same rigor as production code; refactor, organize, and maintain it actively.






Comments
Loading comments…