Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
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
of the crate
use
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.
// BAD: .unwrap() panics with a generic message; no structured Err info on failure#[test]fnparses_valid_port_bad() {
letport: 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:
Idiom 2 — #[should_panic(expected = "...")] with a substring
Always provide expected = so the test fails when code panics for the wrong reason.
// CORRECT: test fails if the panic message does not contain "less than or equal to 100"#[test]#[should_panic(expected = "less than or equal to 100")]fnrejects_over_range() {
Guess::new(200);
}
// BAD: test passes on ANY panic — including an unrelated internal assertion or a panic// injected by a dependency. You have no idea the right condition fired.#[test]#[should_panic]fnrejects_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.
// CORRECT: failure message explains the semantic expectation#[test]fngreeting_includes_name() {
letmsg = greeting("Carol");
assert!(
msg.contains("Carol"),
"expected greeting to contain the user's name, got: `{msg}`",
);
}
#[test]fntotals_match() {
letgot = compute_total(&items);
assert_eq!(
got, 42,
"compute_total returned {got} but expected 42 for input {:?}", &items,
);
}
// BAD: bare assert!(a == b) produces "assertion failed: a == b" — no values printed#[test]fntotals_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.
// src/math.rspubfnadd_two(n: i32) ->i32 { n + 2 }
fninternal_double(n: i32) ->i32 { n * 2 }
#[cfg(test)]mod tests {
use super::*; // imports both pub and private items#[test]fnadd_two_basic() {
assert_eq!(add_two(3), 5);
}
#[test]fninternal_double_basic() {
assert_eq!(internal_double(4), 8); // private fn reachable here
}
}
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
// tests/common/mod.rs — use this path, NOT tests/common.rs// Files in subdirectories of tests/ are NOT compiled as test binaries.pubfnsetup_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.
// BAD: passes on any panic, including the wrong one#[test]#[should_panic]fnbad_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>".
// CORRECT#[test]#[should_panic(expected = "input must be non-negative")]fnbad_input_rejected() {
process_input(-1);
}
# Detector — #[should_panic] lines NOT followed by (expected =
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
// BAD: five .unwrap() calls — first failure gives "called unwrap on Err"; no context#[test]fnfull_flow() {
letconn = connect().unwrap();
letid = insert_user(&conn, "alice").unwrap();
letuser = 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 ?.
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)
// BAD: failure says "assertion failed: left == right" — no values shown#[test]fncomputes_total_bad() {
assert!(compute_total(&items) == 42);
}
Why (Book Ch. 11.1):assert_eq! and assert_ne! print both values on failure:
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.
// CORRECT#[test]fncomputes_total() {
assert_eq!(compute_total(&items), 42, "unexpected total for items: {:?}", &items);
}
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)
// BAD: this test can never fail — it just calls the code and drops the result#[test]fncreates_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.
# Detector — test functions with no assert!/assert_eq!/assert_ne! and no ? or Err return# Collects 30 lines after each #[test] marker; checks the block for assertion keywords.# Heuristic: 30 lines covers almost all real test bodies; review any reported match manually.
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)]
// BAD: this is in src/billing.rs — it imports a crate-internal helper from src/payments/mod.rs// to test a multi-module flow. This crosses module boundaries via crate-internal visibility// and makes the test brittle to internal refactoring.#[cfg(test)]mod tests {
use super::*;
use crate::payments::internal_charge_helper; // accessible because internal_charge_helper// is pub(crate) — crossing module boundaries// via crate-internal visibility is the smell#[test]fnfull_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.
// CORRECT: tests/billing_flow.rs — calls only the public API; no cross-module internalsuse my_crate::billing::process_billing;
use my_crate::payments::ChargeResult;
#[test]fnfull_billing_flow_succeeds() ->Result<(), Box<dyn std::error::Error>> {
letresult: ChargeResult = process_billing(42)?;
assert!(result.is_success());
Ok(())
}
# Detector — use crate:: inside a #[cfg(test)] block (heuristic for cross-module reach)# Finds files that contain both #[cfg(test)] and a use crate:: import referencing another module.
grep -rln '#\[cfg(test)\]' src/ | whileread -r file; doif grep -qE 'use crate::[a-z_]+::[a-z_]'"$file"; then
grep -n 'use crate::'"$file"fidone
Forbidden 6 — Shared Mutable Global State Across Tests
// BAD: tests share a global counter; run order determines whether they passstatic COUNTER: std::sync::Mutex<i32> = std::sync::Mutex::new(0);
#[test]fnincrements_counter() {
*COUNTER.lock().unwrap() += 1;
assert_eq!(*COUNTER.lock().unwrap(), 1); // fails if another test ran first
}
#[test]fncounter_starts_at_zero() {
assert_eq!(*COUNTER.lock().unwrap(), 0); // fails if increments_counter ran first
}
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 Mutexand reset it at test start — but this couples
tests to a teardown contract that is fragile.
// CORRECT: each test owns its state#[test]fnincrements_counter() {
letmut counter = 0i32;
counter += 1;
assert_eq!(counter, 1);
}
# Detector — static mutable or static Mutex in files with #[test]
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
// BAD: no one knows why this is ignored or when it can be re-enabled#[test]#[ignore]fnslow_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.
// CORRECT// ignore: requires a live Postgres instance; run with INTEGRATION=1 cargo test -- --ignored#[test]#[ignore]fnslow_integration_test() {
// ...
}
# Detector — #[ignore] lines not immediately preceded by a reason comment# Uses awk to check whether the line immediately before #[ignore] contains a reason.
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.
# Run all tests
cargo test# Show stdout from passing tests (suppressed by default)
cargo test -- --show-output
# Disable parallelism (required when tests share mutable global state)
cargo test -- --test-threads=1
# Run a single test by name (substring match)
cargo test parse_port_rejects_zero
# Run all tests whose name contains "account"
cargo test account
# Run only a specific integration test file
cargo test --test accounts
# Run only tests marked #[ignore]
cargo test -- --ignored
# Run all tests including 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.
/// Adds two to the given number.////// # Examples////// ```/// let result = my_crate::add_two(3);/// assert_eq!(result, 5);/// ```pubfnadd_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):
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).