When I first started writing Rust, I thought the compiler had my back. And it does—for types, lifetimes, and memory safety. But runtime logic? That’s on me. I learned the hard way after a simple off-by-one error made it into production. Since then, I’ve built a testing habit that saves me from embarrassment and keeps my code honest. These eight strategies are the ones I rely on every day. They work for tiny libraries and sprawling applications alike. I’ll show you exactly how I use them, with code you can steal.
Write unit tests alongside your code with #[cfg(test)]
I put unit tests right next to the implementation, inside a tests module marked with #[cfg(test)]. This module only compiles when I run cargo test, so there’s no bloat in production builds. The best part: I can test private functions directly. No need to expose internals just for testing.
fn is_even(n: i32) -> bool {
n % 2 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_is_even() {
assert!(is_even(0));
}
#[test]
fn odd_numbers_are_not_even() {
assert!(!is_even(3));
}
}
I test the base cases first: zero, positive, negative. Then I add a known value. This catches regression instantly when someone changes the logic.
Create integration tests in a separate tests/ directory
Unit tests know too much about internals. Integration tests treat my crate like a black box. I create a folder called tests/ at the root of my project, and cargo automatically finds every .rs file in there. Each file compiles as its own crate, so I can only use the public API.
// tests/user_service.rs
use my_auth::UserService;
#[test]
fn create_user_returns_ok() {
let svc = UserService::new();
let user = svc.register("alice", "secret");
assert!(user.is_ok());
}
#[test]
fn duplicate_username_fails() {
let svc = UserService::new();
svc.register("bob", "pwd").unwrap();
let second = svc.register("bob", "other");
assert!(second.is_err());
}
I simulate how a real user would call my library. That means I test error paths too, like duplicate usernames or missing fields.
Use doc tests to keep examples in sync with your code
Documentation gets stale. I know because I’ve read countless docs that no longer compile. Rust’s doc tests solve that. Any code block in a /// comment marked with ``` becomes a test. cargo test runs them. If the example breaks, my build fails.
/// Returns the length of a string, counting Unicode grapheme clusters.
///
/// # Example
///
/// ```
/// use my_text::grapheme_len;
/// assert_eq!(grapheme_len("café"), 4);
/// ```
pub fn grapheme_len(s: &str) -> usize {
s.graphemes(true).count()
}
I put the most common usage in the first example. I also show edge cases in hidden lines (prefixed with #). That way the reader sees a clean snippet, but the test still verifies the error handling.
Test error cases and edge conditions explicitly
Happy path tests feel good, but they don’t find the bugs that bite you at 3 AM. I write tests for every error return and every panic I expect. For functions that can fail, I prefer returning a Result and matching on Err. For panics, I use #[should_panic].
fn divide(a: i32, b: i32) -> Result<i32, &'static str> {
if b == 0 {
Err("division by zero")
} else {
Ok(a / b)
}
}
#[test]
fn divide_by_zero_returns_error() {
let result = divide(10, 0);
assert_eq!(result, Err("division by zero"));
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn panic_on_out_of_bounds() {
let v = vec![1, 2, 3];
v[10];
}
I also test boundary values: maximum, minimum, empty collections. One time I forgot to test an empty list in a sorting function. The function panicked. A quick test later, problem solved.
Employ property-based testing with proptest for random inputs
Hand-picked inputs only cover what I think of. Property-based testing generates hundreds of random inputs and checks that a property holds for all of them. I use the proptest crate for this. I define a strategy—like any::<Vec<i32>>()—and write a property.
use proptest::prelude::*;
proptest! {
#[test]
fn sort_maintains_length(mut v: Vec<i32>) {
let len = v.len();
v.sort();
assert_eq!(v.len(), len);
}
#[test]
fn sort_produces_sorted_output(v: Vec<i32>) {
let mut sorted = v.clone();
sorted.sort();
for w in sorted.windows(2) {
assert!(w[0] <= w[1]);
}
}
}
The first test says “sorting doesn’t change the number of elements.” The second says “sorted output is non-decreasing.” These are true for every list, so if they fail I know my sort implementation is broken. I add more properties as I go: idempotence (sorting twice gives the same result), round‑tripping (parse then format yields original), and invariants specific to my domain.
Isolate dependencies with mock objects and trait-based injection
External services are slow, unpredictable, and sometimes unavailable. I don’t want my test suite to depend on a live database. Instead, I define a trait for the dependency, implement it for production, and create a mock for tests. I inject the dependency via generics or trait objects.
pub trait EmailSender {
fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), Error>;
}
pub struct NotificationService<T: EmailSender> {
sender: T,
}
impl<T: EmailSender> NotificationService<T> {
pub fn notify_order_shipped(&self, customer: &str) -> Result<(), Error> {
self.sender.send(customer, "Order shipped", "Your order is on its way!")
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockSender {
sent: std::cell::RefCell<Vec<String>>,
}
impl EmailSender for MockSender {
fn send(&self, to: &str, _subject: &str, _body: &str) -> Result<(), Error> {
self.sent.borrow_mut().push(to.to_string());
Ok(())
}
}
#[test]
fn sends_email_to_correct_recipient() {
let sender = MockSender { sent: Default::default() };
let service = NotificationService { sender };
service.notify_order_shipped("alice@example.com").unwrap();
assert_eq!(service.sender.sent.borrow()[0], "alice@example.com");
}
}
I can also simulate errors by making the mock return Err. This lets me test that my error handling works without ever touching an SMTP server.
Run tests in parallel and measure coverage
cargo test runs tests in parallel by default. That’s fast, but it means I must avoid shared mutable state. I never write tests that modify the same file or environment variable without synchronisation. If I need a temp file, I create a unique one per test or use a crate like tempfile.
To measure coverage, I use cargo-tarpaulin. It tells me which lines never executed. I focus on the uncovered branches, especially in complex match statements.
cargo install cargo-tarpaulin
cargo tarpaulin --out Html --skip-clean
The HTML report shows red lines that need tests. I go after those until the coverage number satisfies my project’s policy (usually 80% or higher). But coverage is a guide, not a goal. I still write tests for tricky logic even if coverage is already green.
Integrate tests into your CI pipeline for early feedback
Writing tests is useless if nobody runs them on every change. I set up GitHub Actions to run cargo test on every push and pull request. I also add clippy for linting and rustfmt for formatting. If any of them fails, the pipeline fails.
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
components: clippy, rustfmt
- run: cargo build --verbose
- run: cargo test --verbose
- run: cargo clippy -- -D warnings
- run: cargo fmt --check
I also run integration tests and property tests separately if they take too long. The key is to fail fast. As soon as a test breaks, I get an email. I fix it before the memory of the change fades.
Combining these eight strategies gives me a safety net that catches everything from trivial typos to design flaws. I write unit tests as I code, doc tests as I document, integration tests for the public API, and property tests for invariants. I mock external dependencies, measure coverage, and let CI enforce it all. My Rust projects rarely have bugs that escape into production. And when they do, I add a test first before fixing the code. That way, the same mistake never happens twice.
I still remember the off‑by‑one bug that haunted me for a week. Now I test boundaries for every loop and index operation. The extra minutes I spend on tests save hours of debugging later. That’s a trade‑off I’ll always take.