Testing in Rust Unit Integration Doc and Property Tests
A guide to testing Rust code: unit tests beside the code, integration tests in tests, doc tests, property tests, snapshots, mocks, async tests and the commands to run them.

Rust ships with a test runner, and most projects use about a third of it. They write a few #[test] functions in the same file as the code, run cargo test, and stop there. The toolbox is bigger than that, and each tool catches a different kind of bug: unit tests pin behavior at the level of one function, integration tests check what a user of the crate sees, doctests keep the documentation honest, and property tests find the inputs you did not think of. This guide builds one small crate and tests it all four ways, with the failures that each kind of test produced along the way, because the failures are where the kinds differ.
The crate is slugify, which turns titles into URL slugs and cuts them to a length. It is small enough to hold in your head and has enough edge cases to be interesting. Everything ran on Rust 1.99 (edition 2024) with proptest as the only extra dependency, and every output block is real.
The code under test
//! Turn titles into URL slugs.
/// Convert a title into a lowercase, hyphen-separated slug.
///
/// Runs of anything that is not an ASCII letter or digit collapse into a single hyphen,
/// and the slug never starts or ends with one.
///
/// ```
/// use slugify::slugify;
///
/// assert_eq!(slugify("Hello, World!"), "hello-world");
/// assert_eq!(slugify(" Rust & WebAssembly "), "rust-webassembly");
/// ```
pub fn slugify(title: &str) -> String {
let mut slug = String::with_capacity(title.len());
let mut pending_hyphen = false;
for c in title.chars() {
if c.is_ascii_alphanumeric() {
if pending_hyphen && !slug.is_empty() {
slug.push('-');
}
pending_hyphen = false;
slug.push(c.to_ascii_lowercase());
} else {
pending_hyphen = true;
}
}
slug
}
/// Cut a slug to at most `max` bytes, ending on a word boundary where possible.
///
/// A single word longer than `max` is cut mid-word, because returning nothing helps nobody.
pub fn truncate_slug(slug: &str, max: usize) -> &str {
if slug.len() <= max {
return slug;
}
// The byte at index `max` is the first one that does not fit. If it is a hyphen, the word
// before it fits exactly, so the search range must include it.
match slug.as_bytes()[..=max].iter().rposition(|&b| b == b'-') {
Some(i) => &slug[..i],
None => &slug[..max],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lowercases_and_joins_words() {
assert_eq!(slugify("Ownership Explained"), "ownership-explained");
}
#[test]
fn collapses_punctuation_runs() {
assert_eq!(slugify("a --- b"), "a-b");
}
#[test]
fn empty_and_symbol_only_titles_give_an_empty_slug() {
assert_eq!(slugify(""), "");
assert_eq!(slugify("!!!"), "");
}
#[test]
fn truncation_prefers_a_word_boundary() {
assert_eq!(truncate_slug("alpha-beta-gamma", 12), "alpha-beta");
assert_eq!(truncate_slug("short", 10), "short");
}
#[test]
fn a_word_that_fits_exactly_is_kept() {
assert_eq!(truncate_slug("ab-cd-ef", 5), "ab-cd");
}
#[test]
fn one_long_word_is_cut_mid_word() {
assert_eq!(truncate_slug("supercalifragilistic", 5), "super");
}
}slugify makes a slug in one pass: ASCII letters and digits are kept and lowercased, and any run of anything else becomes a single hyphen, never at the start or the end. truncate_slug cuts a slug to a byte limit, preferring to end on a word boundary. Both are public. The tests at the bottom are the unit tests, and the doc comment on slugify contains examples that will become the doctests.
Where tests live in a Rust project
Cargo recognizes four places, and they differ in what they can see and how they are compiled.
| Kind | Where | Can see | Compiled as |
|---|---|---|---|
| Unit tests | #[cfg(test)] mod tests in the source file | Private items of that module | Part of the crate |
| Integration tests | Files in tests/ | Only the public API | Each file is its own crate |
| Doctests | Code in /// comments | Only the public API | Each block is its own tiny crate |
| Benchmarks | benches/ | Public API | Separate harness |
The consequence is a division of labor. Put tests of internal helpers and edge cases in unit tests, because they can reach private functions. Put tests of the interface a user depends on in tests/, because they fail to compile when you accidentally make something private or change a signature. Write doctests for anything a reader should be able to copy. Each integration file and each doctest is compiled separately, so a project with many of them builds more slowly than one with the same tests in unit modules; that is a reason to keep integration tests few and meaningful.
Unit tests
The six unit tests in the file are plain functions marked #[test]. Each asserts one behavior and its name states the claim: lowercases_and_joins_words, collapses_punctuation_runs, empty_and_symbol_only_titles_give_an_empty_slug. Read the list of names alone and you have the specification. That is the standard to hold test names to; a name like test1 or works tells the next person nothing when it fails.
Edge cases deserve their own tests, not extra assertions in an existing one. The empty string and a string of only symbols both produce an empty slug, a case that is easy to get wrong with an off-by-one at the hyphen logic. A separate test for it means a failure message says which case broke.
Two attributes widen what a unit test can express. #[should_panic(expected = "...")] asserts that code panics, and the expected substring makes sure it panics for the right reason, not because of an unrelated bug. And a test can return Result<(), E>, which lets you use ? inside it and fail with the error's message instead of an unwrap panic:
#[test]
fn works_in_a_result_returning_test() -> Result<(), String> {
let slug = slugify("Result Tests");
if slug != "result-tests" {
return Err(format!("unexpected slug {slug:?}"));
}
Ok(())
}Integration tests: the public API only
A file in tests/ is compiled as a separate crate that depends on yours, so it can only call what you made pub. That makes it the right place to test how the pieces work together as a user would use them.
//! Integration tests see only the public API, the way a user's crate would.
use slugify::{slugify, truncate_slug};
#[test]
fn a_realistic_title_becomes_a_short_url_friendly_slug() {
let title = "Ownership in Rust Explained With a Mental Model That Holds";
let full = slugify(title);
let slug = truncate_slug(&full, 30);
assert_eq!(slug, "ownership-in-rust-explained");
assert!(slug.len() <= 30);
}
#[test]
fn works_in_a_result_returning_test() -> Result<(), String> {
let slug = slugify("Result Tests");
if slug != "result-tests" {
return Err(format!("unexpected slug {slug:?}"));
}
Ok(())
}This test slugifies a long title and truncates it to 30 bytes. It earned its place by failing, twice, in useful ways.
The first failure: a design flaw, found by a test
The first version of truncate_slug cut at the byte limit and trimmed a trailing hyphen. The integration test expected the slug ownership-in-rust-explained, and got this (the test had a longer name then):
thread 'a_realistic_title_round_trips_through_both_functions' panicked at tests/public_api.rs:8:5:
assertion `left == right` failed
left: "ownership-in-rust-explained-wi"
right: "ownership-in-rust-explained"The unit tests had passed, because they asserted what the function did: it never ended on a hyphen. The integration test asserted what a user wants, a slug that does not end in half a word. The function was correct against its own tests and wrong against its purpose. The fix was a design change, cutting at the last word boundary, which is the truncate_slug in the listing. Unit tests check that code does what you meant. Integration tests check that you meant the right thing.
The second failure: the compiler objects to the test
The rewritten test went on to fail to compile, with an error about lifetimes, a good reminder that tests are ordinary Rust code and obey the borrow checker:
error[E0716]: temporary value dropped while borrowed
--> tests/public_api.rs:7:31
|
7 | let slug = truncate_slug(&slugify(title), 30);
| ^^^^^^^^^^^^^^ - temporary value is freed at the end of this statement
| |
| creates a temporary value which is freed while still in use
8 | assert_eq!(slug, "ownership-in-rust-explained");
| ----------------------------------------------- borrow later used heretruncate_slug returns a &str borrowed from its argument, and the argument was the String returned by slugify, a temporary that died at the end of the statement. The listing above has the fix the compiler suggested: bind the full slug to a variable so it lives as long as the slice. Notice that the signature fn truncate_slug(slug: &str, max: usize) -> &str made this mistake impossible to ship. Returning a borrowed slice, with no copy, is the design that the compiler checks for you.
Doctests: documentation that cannot rot
The examples inside the /// comment on slugify are compiled and run by cargo test as their own tests:
Doc-tests slugify
test src/lib.rs - slugify (line 8) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sIf someone renames slugify or changes its behavior, the example in the documentation fails the build, so the docs cannot drift from the code. Write examples as a user would, with use slugify::slugify; at the top, because a doctest is a separate crate that sees only the public API. Lines beginning with # are compiled but hidden in the rendered docs, which is how you hide setup like fn main() -> Result<(), Error> { and keep the visible example short. Use no_run for code that should compile but not execute, such as a network call, and should_panic or compile_fail blocks to document what does not work.
Property tests: the inputs you did not think of
Example-based tests check the cases you imagined. A property test states a rule that must hold for every input, and a library generates hundreds of random inputs looking for one that breaks it. proptest is the standard crate, added with cargo add --dev proptest. Three properties describe what a slug is:
use proptest::prelude::*;
use slugify::slugify;
proptest! {
#[test]
fn output_uses_only_allowed_characters(title in ".*") {
let slug = slugify(&title);
prop_assert!(slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
}
#[test]
fn never_starts_or_ends_with_a_hyphen_or_doubles_one(title in ".*") {
let slug = slugify(&title);
prop_assert!(!slug.starts_with('-') && !slug.ends_with('-'));
prop_assert!(!slug.contains("--"));
}
#[test]
fn slugifying_a_slug_changes_nothing(title in ".*") {
let once = slugify(&title);
prop_assert_eq!(slugify(&once), once);
}
}
use slugify::truncate_slug;
/// The longest prefix of `slug` that fits in `max` bytes and ends at a word boundary,
/// or the first `max` bytes if the first word alone is too long. Written the slow, obvious way.
fn expected_truncation(slug: &str, max: usize) -> &str {
if slug.len() <= max {
return slug;
}
let boundary = (1..=max).rev().find(|&i| slug.as_bytes()[i] == b'-');
match boundary {
Some(i) => &slug[..i],
None => &slug[..max],
}
}
proptest! {
#[test]
fn truncation_matches_the_obvious_implementation(
slug in "[a-z]{1,6}(-[a-z]{1,6}){0,5}",
max in 1usize..40,
) {
prop_assert_eq!(truncate_slug(&slug, max), expected_truncation(&slug, max));
}
}The first says the output contains only lowercase letters, digits and hyphens, for any string of any characters. The second says it never starts or ends with a hyphen and never has two in a row. The third says slugifying a slug changes nothing, which is idempotence, and it is a strong check that the function has no hidden state or ordering quirk. The strategy ".*" generates arbitrary strings, including Unicode, empty strings and strange characters, that a human writing examples would not type.
The fourth property tests truncate_slug against an obviously correct, slow implementation. This is the most powerful pattern in property testing: write the simplest thing that is clearly right, even if it is inefficient, and assert that the real code agrees on every input. The inputs are generated by a pattern, [a-z]{1,6}(-[a-z]{1,6}){0,5}, that produces realistic slugs of one to six words, and a random limit from 1 to 39. When it ran against the first word-boundary version of the function, it found a bug the hand-written tests had missed:
thread 'truncation_matches_the_obvious_implementation' panicked at tests/properties.rs:40:1:
Test failed: assertion failed: `(left == right)`
left: `"aa-a"`,
right: `"aa-a-aaaaaa"` at tests/properties.rs:46.
minimal failing input: slug = "aa-a-aaaaaa-a", max = 11
successes: 11
local rejects: 0
global rejects: 0The line to read is minimal failing input. proptest found a failing case and then shrank it, trying simpler inputs until it reached the smallest one that still fails. With the slug aa-a-aaaaaa-a and a limit of 11, the function returned aa-a, but the first three words aa-a-aaaaaa fit in exactly 11 bytes. The bug: the code searched for the last hyphen in slug[..max], which excludes the byte at index 11. That byte is itself a hyphen, meaning the word before it fits exactly and should have been kept. The fix extends the search range to include index max:
match slug.as_bytes()[..=max].iter().rposition(|&b| b == b'-') {
Some(i) => &slug[..i],
None => &slug[..max],
}A hand-written regression test, a_word_that_fits_exactly_is_kept, now pins that case forever, using the example ab-cd-ef with a limit of 5, and proptest saves failing inputs in a proptest-regressions file next to the test so the exact case is replayed on every run. Commit that file. The practice is: when a property test finds a bug, turn the minimal failing input into an ordinary unit test, so the knowledge lives in a readable example and not only in a random seed.
The whole suite
With all four kinds in place, cargo test runs them in sequence, and the final result:
Running unittests src/lib.rs
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running tests/properties.rs
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running tests/public_api.rs
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests slugify
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outThirteen tests in four groups, in the time it takes to blink. Cargo prints a separate summary per group. By default it stops after the first group that fails, so the groups after it never run. Add --no-fail-fast in CI so one red group does not hide failures in the rest.
Running a subset
A test suite earns its keep when it is fast to run the part you are working on. Cargo takes a substring filter, and everything after -- goes to the test binary:
cargo test boundary # only tests whose name contains "boundary"
cargo test --test properties # only the file tests/properties.rs
cargo test --lib # only the unit tests
cargo test --doc # only the doctests
cargo test -- --nocapture # show println! output from passing tests
cargo test -- --test-threads=1 # run serially, for tests that share state
cargo test -- --ignored # run tests marked #[ignore]
cargo test -- --list # print every test name without runningFiltering boundary ran exactly one test, truncation_prefers_a_word_boundary, and reported the others as filtered out. Use #[ignore = "reason"] for tests that are slow or need external resources, and run them on a schedule with --ignored. Tests run in parallel threads by default, so a test that mutates a global, a file at a fixed path or an environment variable can interfere with another; give each test its own temporary directory instead of serializing the suite.
Testing async code without waiting
Code that sleeps, retries or times out is the classic flaky test: either the test sleeps for real, and the suite takes minutes, or it uses short sleeps, and fails on a loaded CI machine. Tokio solves it with a paused clock. Add the test-util feature as a dev-dependency and mark the test #[tokio::test(start_paused = true)]. Time then advances only when every task is idle, jumping straight to the next timer, so a ten-minute backoff completes instantly and deterministically.
use std::time::Duration;
use tokio::time::{sleep, timeout, Instant};
/// Retry an operation up to 3 times, waiting 1s, then 2s between attempts.
async fn with_retries<F, Fut>(mut op: F) -> Result<u32, String>
where
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = Result<u32, String>>,
{
let mut delay = Duration::from_secs(1);
for attempt in 1..=3 {
match op(attempt).await {
Ok(v) => return Ok(v),
Err(e) if attempt == 3 => return Err(e),
Err(_) => {
sleep(delay).await;
delay *= 2;
}
}
}
unreachable!()
}
fn main() {}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn waits_one_then_two_seconds_between_attempts_without_really_waiting() {
let start = Instant::now();
let result = with_retries(|attempt| async move {
if attempt < 3 { Err(format!("attempt {attempt} failed")) } else { Ok(attempt) }
})
.await;
assert_eq!(result, Ok(3));
// 1s + 2s of virtual time passed; the test itself took microseconds.
assert_eq!(start.elapsed(), Duration::from_secs(3));
}
#[tokio::test(start_paused = true)]
async fn a_hung_operation_times_out() {
let slow = with_retries(|_| async {
sleep(Duration::from_secs(3600)).await;
Ok(1)
});
assert!(timeout(Duration::from_secs(5), slow).await.is_err());
}
}running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe first test fails twice and succeeds on the third attempt, and asserts that exactly three seconds of virtual time passed, one second plus two, matching the backoff schedule. The whole test finished in under a millisecond of real time. The second test hands the retry loop an operation that sleeps for an hour and asserts that a five-second timeout fires, again instantly. Both are deterministic: there is no race between the clock and the code, because the clock only moves when the test lets it. The same technique tests rate limiters, caches with expiry, heartbeat loops and anything else governed by time, and it is the main reason to prefer tokio::time::sleep over std::thread::sleep in async code you intend to test.
Shared helpers across integration tests
Integration test files are separate crates, so they cannot share code the way modules do. The convention is a directory module: tests/common/mod.rs, which Cargo does not treat as a test file because it is in a subdirectory, and each test file declares mod common;. Put fixtures there: a function that builds a temporary directory with sample files, a builder for a config with sensible defaults, a helper that starts a test server on a random port and returns its address.
use std::path::PathBuf;
/// A scratch directory that is removed when the value is dropped.
pub struct Scratch(pub PathBuf);
impl Scratch {
pub fn new(name: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}The Drop impl is what makes the fixture reliable: cleanup happens even when the test panics, because unwinding drops locals. Using the process id in the name keeps two test binaries from clobbering each other; for tests in the same binary, add a counter or use the tempfile crate, which does all of this. A related rule keeps parallel tests honest: never rely on the current working directory or on a fixed path, since another test may change or remove it at the same moment.
Making code testable
Whether a program is easy to test is mostly decided by its design, not by the test framework. Three habits make the difference.
Separate logic from effects. A function that takes a &str and returns a String is trivial to test. A function that reads a file, parses it and writes a database row is not, so split it into three: read the bytes, a pure function that parses them, and the write. Test the pure middle exhaustively, and test the ends lightly.
Take collaborators as parameters. A function that calls SystemTime::now() directly cannot be tested for time-dependent behavior; one that accepts the time as an argument, or a small trait, can. In Rust the idiom is a trait with a real and a fake implementation, passed as a generic parameter, and the compiler monomorphizes it away in production.
Return data, not side effects. A function that returns a Vec of events can be asserted against; one that prints cannot, without capturing the output.
Common mistakes in Rust test suites
- Asserting on
Debugoutput. A test that comparesformat!("{:?}", x)breaks when someone derivesDebugdifferently or reorders a field. - Testing private implementation details in integration tests, or public behavior only in unit tests, so refactors break tests that should not care.
unwrap()everywhere in tests. It is acceptable there, but aResult-returning test with?gives a better failure message.- Tests that depend on the order of
HashMapiteration, which is randomized per process, so the failure appears once in a hundred runs. Sort before asserting, or compare as sets. - Flaky time-based tests with
sleep. Inject a clock or usetokio::time::pause()in async tests. - Property tests with a strategy so narrow that it never reaches the interesting inputs, or so broad that it spends its cases on rejected ones. Check
local rejectsin the output.
When to stop writing tests
Tests are code, and code has a cost. A getter does not need a test. A thin wrapper over a well-tested library function needs one at most. The best value comes from three places: pure logic with edge cases, which property tests cover; interfaces other code depends on, which integration tests pin; and bugs you have already fixed once, which regression tests keep fixed. Measure coverage with cargo llvm-cov if you like, but treat the number as a way to find untested branches and not as a goal; a suite with 95 percent line coverage and no assertions about behavior proves little. For the next layer of confidence, fuzzing finds crashes in parsers that no hand-written test will, and continuous integration is where all of this runs on every change.
A final habit is worth building into the workflow: run the suite the way CI will, not only the way you do. That means cargo test --all-features, the same command with --release occasionally (overflow checks and debug assertions are off, so some bugs only appear there), and a run with the oldest toolchain you claim to support. Three commands, a minute of attention, and the tests you wrote stay meaningful on machines other than your own.


