| name | rust-testing |
| description | > Use when this capability is needed. |
Test Writing (Rust)
When to Use
- Before writing any
#[test] function for the first time in a module.
- When a test failure message is unhelpful (the diff says nothing about why).
- When investigating a
#[should_panic] test that passes even though the wrong panic fired.
- When deciding whether a test belongs in
#[cfg(test)] mod tests or in tests/.
- When adding shared helper logic for integration tests.
- When writing a test that calls fallible code (
? is available in tests).
This skill covers how to write tests. For running the full gate suite (fmt, clippy, sqlx
offline, migration safety, hydration lints) see quality-gates.
Unit Tests vs. Integration Tests
| Concern | Unit Test | Integration Test |
|---|
| Location | #[cfg(test)] mod tests { } inside src/ file | tests/<name>.rs at crate root |
#[cfg(test)] needed? | Yes — keeps test code out of production build | No — Cargo knows the tests/ dir |
| Accesses private functions? | Yes — child module sees parent's private items | No — uses only pub API |
| Compiled as | Same crate as the code under test | Separate crate per file |
use of the crate | use super::*; | use mycrate::whatever; |
| Purpose | Test a single function/module in isolation | Verify that modules work together end-to-end |
"Writing both kinds of tests is important to ensure that the pieces of your library are doing
what you expect them to, separately and together." — The Book, Ch. 11.3
Core Idioms
Idiom 1 — Return Result<(), E> and propagate with ?
Returning Result from a test enables ? inside the body. This is cleaner than chaining
.unwrap() on every fallible call and produces a structured failure on Err.
#[test]
fn parses_valid_port() -> Result<(), std::num::ParseIntError> {
let port: u16 = "8080".parse()?;
assert_eq!(port, 8080);
Ok(())
}
#[test]
fn parses_valid_port_bad() {
let port: u16 = "8080".parse().unwrap();
assert_eq!(port, 8080);
}
Note: you cannot combine #[should_panic] with a Result-returning test. To assert that an
operation returns Err, use assert!(result.is_err()) inside a Result-returning test:
#[test]
fn rejects_non_numeric() -> Result<(), String> {
let result = "xyz".parse::<u16>();
assert!(result.is_err());
Ok(())
}
Idiom 2 — #[should_panic(expected = "...")] with a substring
Always provide expected = so the test fails when code panics for the wrong reason.
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn rejects_over_range() {
Guess::new(200);
}
#[test]
#[should_panic]
fn rejects_over_range_bad() {
Guess::new(200);
}
Idiom 3 — assert_eq! / assert_ne! with a custom message
Custom messages are formatted with format! and printed only on failure. They document
what was expected, not just that equality failed.
#[test]
fn greeting_includes_name() {
let msg = greeting("Carol");
assert!(
msg.contains("Carol"),
"expected greeting to contain the user's name, got: `{msg}`",
);
}
#[test]
fn totals_match() {
let got = compute_total(&items);
assert_eq!(
got, 42,
"compute_total returned {got} but expected 42 for input {:?}", &items,
);
}
#[test]
fn totals_match_bad() {
assert!(compute_total(&items) == 42);
}
Idiom 4 — Unit tests in #[cfg(test)] mod tests next to the code
The standard location for unit tests is a tests submodule inside the same file. The
#[cfg(test)] attribute means the module is compiled only during cargo test — it does not
inflate your production binary.
pub fn add_two(n: i32) -> i32 { n + 2 }
fn internal_double(n: i32) -> i32 { n * 2 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_two_basic() {
assert_eq!(add_two(3), 5);
}
#[test]
fn internal_double_basic() {
assert_eq!(internal_double(4), 8);
}
}
Idiom 5 — Integration tests in tests/ for black-box, public-API coverage
Each file in tests/ is compiled as its own crate. It can only call pub items.
Use this for end-to-end flow tests that cross module boundaries.
my_crate/
├── src/
│ └── lib.rs
└── tests/
├── common/
│ └── mod.rs ← shared helpers; NOT treated as a test file
└── accounts.rs ← integration test file; no #[cfg(test)] needed
use my_crate::accounts::{create_account, find_account};
mod common;
#[test]
fn round_trip_create_find() -> Result<(), Box<dyn std::error::Error>> {
let db = common::setup_test_db();
let id = create_account(&db, "alice")?;
let acc = find_account(&db, id)?;
assert_eq!(acc.name, "alice");
Ok(())
}
pub fn setup_test_db() -> TestDb { }
Idiom 6 — Descriptive test names that read as sentences
Test names appear verbatim in cargo test output. A descriptive name is the first
line of the failure report.
#[test] fn larger_rectangle_can_hold_smaller() { ... }
#[test] fn parse_port_rejects_zero() { ... }
#[test] fn create_user_returns_conflict_on_duplicate_email() { ... }
#[test] fn test1() { ... }
#[test] fn it_works() { ... }
#[test] fn check() { ... }
Forbidden Patterns
Forbidden 1 — #[should_panic] Without expected =
#[test]
#[should_panic]
fn bad_input_rejected() {
process_input(-1);
}
Why (Book Ch. 11.1): Without expected =, the test passes even when a completely
unrelated panic fires (e.g., an index-out-of-bounds in a dependency, a debug assertion, an
.unwrap() on an unrelated value). You get a false green.
Fix: Add expected = "<substring of the panic message>".
#[test]
#[should_panic(expected = "input must be non-negative")]
fn bad_input_rejected() {
process_input(-1);
}
grep -n '#\[should_panic\]' src/ tests/ -r | grep -v 'expected'
Caveat: the grep catches the attribute form #[should_panic]; the equivalent
#[should_panic(expected = "...")] is on the same line and won't match.
Forbidden 2 — .unwrap() Chains in Tests Instead of Returning Result
#[test]
fn full_flow() {
let conn = connect().unwrap();
let id = insert_user(&conn, "alice").unwrap();
let user = find_user(&conn, id).unwrap();
assert_eq!(user.name, "alice");
}
Why (Book Ch. 11.1): ? in a Result-returning test propagates the actual error
value, giving a useful failure message. A chain of .unwrap() panics with "called
unwrap() on an Err value" and swallows the inner Err.
Fix: Declare -> Result<(), YourErrorType> and use ?.
#[test]
fn full_flow() -> Result<(), Box<dyn std::error::Error>> {
let conn = connect()?;
let id = insert_user(&conn, "alice")?;
let user = find_user(&conn, id)?;
assert_eq!(user.name, "alice");
Ok(())
}
grep -rn '\.unwrap()' src/ tests/ | grep -v '#\[cfg(test\|// @ok-in-test'
Note: unwrap() is acceptable for constructing test fixtures where the value is
hard-coded and obviously valid (e.g., "127.0.0.1".parse().unwrap()). The smell is
chains of .unwrap() on production code paths. See rust-error-handling — that skill
explicitly permits .unwrap() in #[cfg(test)] blocks for fixture setup.
Forbidden 3 — assert!(a == b) Instead of assert_eq!(a, b)
#[test]
fn computes_total_bad() {
assert!(compute_total(&items) == 42);
}
Why (Book Ch. 11.1): assert_eq! and assert_ne! print both values on failure:
assertion `left == right` failed
left: 37
right: 42
assert!(a == b) shows only the source expression. On a CI log without a debugger, the
difference between "got 37, expected 42" and "assertion failed" can take minutes.
Fix: Replace assert!(a == b) with assert_eq!(a, b) (and optionally add a message).
Types must implement PartialEq + Debug; add #[derive(PartialEq, Debug)] if needed.
#[test]
fn computes_total() {
assert_eq!(compute_total(&items), 42, "unexpected total for items: {:?}", &items);
}
grep -rn 'assert!(.* == ' src/ tests/ | grep -v '//'
grep -rn 'assert!(.* != ' src/ tests/ | grep -v '//'
Caveat: the pattern substring-matches debug_assert!(a == b), which is a distinct macro
(stripped in release builds) and may appear legitimately. Review matches to confirm the
hit is assert! rather than debug_assert!. Lines with a trailing // comment may
slip through grep -v '//'; inspect borderline cases manually.
Forbidden 4 — Test Body With No Assertion (Always Passes)
#[test]
fn creates_user() {
let _ = create_user("alice");
}
Why (convention — not a named Book rule; the closest anchor is the assertion-macros section in Book Ch. 11.1): A test with no assertion (no assert!, assert_eq!, assert_ne!, panic!,
or returned Err) is an always-green placeholder. It wastes CI time and provides false
confidence. The function could return any garbage value and the test still reports ok.
Fix: Assert the return value, or if testing for no-panic, document that explicitly
and at minimum assert a property of the returned value.
#[test]
fn creates_user() -> Result<(), AppError> {
let user = create_user("alice")?;
assert_eq!(user.name, "alice");
Ok(())
}
grep -rn -A30 '#\[test\]' src/ tests/ \
| awk '
/^[^-].*#\[test\]/ { capture=1; block=$0; next }
/^--$/ {
if (capture && block !~ /assert[_!]|Ok\(|Err\(|\?/) print block
capture=0; block=""; next
}
capture { block = block "\n" $0 }
END {
if (capture && block !~ /assert[_!]|Ok\(|Err\(|\?/) print block
}
' 2>/dev/null \
|| echo "run manually: check each #[test] fn for at least one assertion"
Forbidden 5 — Integration-Style Tests Reaching Private Internals via #[cfg(test)]
#[cfg(test)]
mod tests {
use super::*;
use crate::payments::internal_charge_helper;
#[test]
fn full_billing_flow() {
internal_charge_helper(42);
}
}
Why (Book Ch. 11.3): Unit tests placed with #[cfg(test)] in a source file legitimately
access that file's private helpers. But if your test is exercising behavior that crosses
multiple modules via internal symbols, it belongs in tests/ as a black-box integration
test against the pub API. Reaching deeply into other modules' pub(crate) implementations
from a unit-test module creates false coupling and hides design problems.
Fix: Move cross-module flow tests to tests/ and call only the public interface.
If the flow cannot be tested without accessing crate-internal helpers, those helpers probably
need to be promoted to fully pub with intent, or the design needs refactoring.
use my_crate::billing::process_billing;
use my_crate::payments::ChargeResult;
#[test]
fn full_billing_flow_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let result: ChargeResult = process_billing(42)?;
assert!(result.is_success());
Ok(())
}
grep -rln '#\[cfg(test)\]' src/ | while read -r file; do
if grep -qE 'use crate::[a-z_]+::[a-z_]' "$file"; then
grep -n 'use crate::' "$file"
fi
done
Forbidden 6 — Shared Mutable Global State Across Tests
static COUNTER: std::sync::Mutex<i32> = std::sync::Mutex::new(0);
#[test]
fn increments_counter() {
*COUNTER.lock().unwrap() += 1;
assert_eq!(*COUNTER.lock().unwrap(), 1);
}
#[test]
fn counter_starts_at_zero() {
assert_eq!(*COUNTER.lock().unwrap(), 0);
}
Why (Book Ch. 11.2): Cargo runs tests in parallel by default (--test-threads defaults
to the number of logical CPUs). Tests sharing mutable global state race with each other and
produce order-dependent results — green locally (serial run), red on CI (parallel run), or
intermittently red depending on the scheduler.
Fix options:
- Give each test its own local state (preferred).
- Run the affected tests serially with
cargo test -- --test-threads=1 (documents the
constraint; slow; use only as a last resort).
- Protect shared state with a
Mutex and reset it at test start — but this couples
tests to a teardown contract that is fragile.
#[test]
fn increments_counter() {
let mut counter = 0i32;
counter += 1;
assert_eq!(counter, 1);
}
grep -rn 'static .*Mutex\|static mut ' src/ tests/ \
| grep -v '// @test-global-ok'
Caveat: this heuristic matches all static Mutex/static mut globals, including
production registries (OnceLock<Mutex<_>>, metrics counters, connection pools) that
are safe and intentional. Review each hit; flag only those declared inside a #[cfg(test)]
module or a test helper file, not every prod-code global.
Forbidden 7 — #[ignore] Without a Reason Comment
#[test]
#[ignore]
fn slow_integration_test() {
}
Why (Book Ch. 11.2): #[ignore] is a legitimate tool for tests that require external
resources or take minutes to run. Without a reason, ignored tests become permanently invisible
dead weight — no one re-enables them and coverage silently drops.
Fix: Add a // ignore: <reason> comment immediately above #[ignore], stating why and
the condition under which it should be re-enabled.
#[test]
#[ignore]
fn slow_integration_test() {
}
grep -rn -B1 '#\[ignore\]' src/ tests/ \
| awk '
/\/\/ ignore:|@ignore-ok/ { skip=1; next }
/#\[ignore\]/ { if (!skip) print; skip=0; next }
{ skip=0 }
'
Running Tests — Quick Reference
Full gate (fmt + clippy + sqlx + hydration lints): see quality-gates.
cargo test
cargo test -- --show-output
cargo test -- --test-threads=1
cargo test parse_port_rejects_zero
cargo test account
cargo test --test accounts
cargo test -- --ignored
cargo test -- --include-ignored
Argument separation: arguments before -- are for cargo test; arguments after --
are passed to the test binary. Example: cargo test --test accounts -- --show-output.
Doc Tests
Doc tests live in /// doc comments and are run by cargo test as a third category
(separate section in output). They serve as living documentation.
pub fn add_two(n: i32) -> i32 { n + 2 }
Rules:
- Doc tests compile and run as their own mini-crate — they test the
pub API only.
- They run even in library crates that have no
tests/ directory.
# ignored_setup_line (lines starting with #) are included in compilation but hidden
in rendered docs.
- Mark a block
no_run if it requires runtime resources: ```no_run.
Book References
All section titles and quotes are from The Rust Programming Language (doc.rust-lang.org/book):
-
Ch. 11.0 — "Writing Automated Tests" (chapter intro overview)
https://doc.rust-lang.org/book/ch11-00-testing.html
-
Ch. 11.1 — "How to Write Tests": test lifecycle (set up → run → assert), #[test],
assertion macros, #[should_panic(expected=)], Result<T,E> tests, custom failure messages
https://doc.rust-lang.org/book/ch11-01-writing-tests.html
-
Ch. 11.2 — "Controlling How Tests Are Run": --test-threads, --show-output,
name filtering, #[ignore], --ignored
https://doc.rust-lang.org/book/ch11-02-running-tests.html
-
Ch. 11.3 — "Test Organization": unit tests + #[cfg(test)], private function testing,
integration tests in tests/, tests/common/mod.rs, doc tests
https://doc.rust-lang.org/book/ch11-03-test-organization.html
Related Skills
quality-gates — runs cargo test (and fmt/clippy/sqlx/hydration gates) as the
final verification step. This skill tells you what to write; quality-gates tells you
when and how to run it.
rust-error-handling — companion for Result<T, E> test patterns; clarifies that
.unwrap() is acceptable in #[cfg(test)] fixture setup (Forbidden 2 caveat above).
Source: adelabdelgawad/rust-fullstack-agents — distributed by TomeVault.