|
| 1 | +--- |
| 2 | +name: writing-rust-tests |
| 3 | +description: Use when writing, adding, or running any Rust test in this workspace (unit tests, `cargo test`, integration tests under `crates/*/tests/`, or new test coverage). Enforces rstest over free-floating `#[test]`, parametrized `#[case]` over duplicated test functions and hand-rolled loops, and this repo's clippy/tempfile/assert_cmd conventions. |
| 4 | +--- |
| 5 | + |
| 6 | +# Writing Rust Tests |
| 7 | + |
| 8 | +## Overview |
| 9 | + |
| 10 | +This is a Cargo workspace (edition 2024). Tests here follow three non-negotiable defaults: |
| 11 | + |
| 12 | +1. **`#[rstest]`, not bare `#[test]`.** Every test function is annotated `#[rstest]`, even when it takes no cases yet. This keeps the whole suite uniform and lets any test grow cases without a rewrite. |
| 13 | +2. **Parametrize with `#[case]`, never duplicate.** Two or more test functions that differ only in input/expected values ARE ONE parametrized test. A hand-rolled `for` loop over inputs IS a parametrized test written wrong. |
| 14 | +3. **Reach for `proptest` when the property holds over a whole domain**, not just the few points you picked. |
| 15 | + |
| 16 | +**Violating the letter of these rules is violating the spirit.** "It's only two cases" and "a loop is basically the same" are how the suite rots into scattered, unreadable `#[test]` functions. |
| 17 | + |
| 18 | +## When to Use |
| 19 | + |
| 20 | +- Adding a new function/type and covering it with tests. |
| 21 | +- Adding cases to existing tests, or a new `crates/<crate>/tests/*.rs` integration test. |
| 22 | +- The user says "write tests", "add coverage", "test this", or "run the tests". |
| 23 | +- You are about to write `#[test]`, a `for` loop inside a test, or the second near-identical test function. |
| 24 | + |
| 25 | +## The Decision |
| 26 | + |
| 27 | +```dot |
| 28 | +digraph t { |
| 29 | + "What am I testing?" [shape=diamond]; |
| 30 | + "Same logic, different inputs?" [shape=diamond]; |
| 31 | + "Property true over a whole domain?" [shape=diamond]; |
| 32 | + "rstest + #[case] per input" [shape=box]; |
| 33 | + "proptest! block" [shape=box]; |
| 34 | + "single #[rstest] fn" [shape=box]; |
| 35 | +
|
| 36 | + "What am I testing?" -> "Same logic, different inputs?"; |
| 37 | + "Same logic, different inputs?" -> "rstest + #[case] per input" [label="yes"]; |
| 38 | + "Same logic, different inputs?" -> "Property true over a whole domain?" [label="no"]; |
| 39 | + "Property true over a whole domain?" -> "proptest! block" [label="yes"]; |
| 40 | + "Property true over a whole domain?" -> "single #[rstest] fn" [label="no"]; |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +`rstest` and `proptest` are already workspace dev-deps (see `crates/hm-common`). If a crate needs them, add to its `[dev-dependencies]`. |
| 45 | + |
| 46 | +## Core Pattern |
| 47 | + |
| 48 | +Collapse duplicated functions and hand-rolled loops into one parametrized `#[rstest]`. |
| 49 | + |
| 50 | +**Before** — scattered `#[test]` functions + a loop doing parametrization by hand: |
| 51 | + |
| 52 | +```rust |
| 53 | +#[cfg(test)] |
| 54 | +mod tests { |
| 55 | + use super::*; |
| 56 | + |
| 57 | + #[test] |
| 58 | + fn one_is_singular() { assert_eq!(pluralize(1, "file", "files"), "file"); } |
| 59 | + |
| 60 | + #[test] |
| 61 | + fn zero_is_plural() { assert_eq!(pluralize(0, "file", "files"), "files"); } |
| 62 | + |
| 63 | + #[test] |
| 64 | + fn two_is_plural() { assert_eq!(pluralize(2, "file", "files"), "files"); } |
| 65 | + |
| 66 | + #[test] |
| 67 | + fn range_is_plural_except_one() { |
| 68 | + for count in 0..=5 { |
| 69 | + let expected = if count == 1 { "item" } else { "items" }; |
| 70 | + assert_eq!(pluralize(count, "item", "items"), expected, "count = {count}"); |
| 71 | + } |
| 72 | + } |
| 73 | +} |
| 74 | +``` |
| 75 | + |
| 76 | +**After** — one parametrized test; each case names itself and fails independently: |
| 77 | + |
| 78 | +```rust |
| 79 | +#[cfg(test)] |
| 80 | +mod tests { |
| 81 | + use super::*; |
| 82 | + use rstest::rstest; |
| 83 | + |
| 84 | + #[rstest] |
| 85 | + #[case::one_singular(1, "file")] |
| 86 | + #[case::zero_plural(0, "files")] |
| 87 | + #[case::two_plural(2, "files")] |
| 88 | + #[case::large_plural(1_000_000, "files")] |
| 89 | + #[case::max_plural(usize::MAX, "files")] |
| 90 | + fn selects_singular_only_for_one(#[case] count: usize, #[case] expected: &str) { |
| 91 | + assert_eq!(pluralize(count, "file", "files"), expected); |
| 92 | + } |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +A hand-rolled loop reports only the first failing iteration and hides the input in a message string; five `#[case]`s each show up as a named subtest (`selects_singular_only_for_one::case_1_one_singular`) and all run even when one fails. |
| 97 | + |
| 98 | +When the claim is "true for *every* value, not just these", use `proptest` (see `crates/hm-common/src/format.rs` for a real example): |
| 99 | + |
| 100 | +```rust |
| 101 | +proptest! { |
| 102 | + #[test] |
| 103 | + fn only_exactly_one_is_singular(count in any::<usize>()) { |
| 104 | + let got = pluralize(count, "file", "files"); |
| 105 | + prop_assert_eq!(got == "file", count == 1); |
| 106 | + } |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +## Repo Conventions (do not skip) |
| 111 | + |
| 112 | +- **Clippy is strict workspace-wide.** `unwrap_used`, `expect_used`, and `panic` are `warn`. Test modules that unwrap/assert-panic need a module-level allow WITH a reason: |
| 113 | + ```rust |
| 114 | + #[cfg(test)] |
| 115 | + #[allow(clippy::unwrap_used, reason = "test setup and assertions")] |
| 116 | + mod tests { ... } |
| 117 | + ``` |
| 118 | +- **Filesystem tests use `tempfile::tempdir()`**, never a hardcoded `/tmp` path. See `crates/hm-common/src/fs.rs`. |
| 119 | +- **Integration tests** live in `crates/<crate>/tests/*.rs`. CLI tests drive the built binary with `assert_cmd::Command::cargo_bin("hm")` + `predicates`; HTTP is mocked with `wiremock`. Reuse the `crates/hm/tests/common/mod.rs` helpers (`hm_bin`, `hm_command`) instead of re-wiring env vars. |
| 120 | +- **Prefer stderr assertions** for CLI output — this repo routes user messages through `tracing` (stderr), not `println`. |
| 121 | + |
| 122 | +## Running Tests |
| 123 | + |
| 124 | +Plain `cargo test` — there is no nextest/just/make wrapper here. Always scope while iterating: |
| 125 | + |
| 126 | +- One crate: `cargo test -p hm-common` |
| 127 | +- One test/module by name: `cargo test -p hm-common plural::` |
| 128 | +- Whole workspace before you claim done: `cargo test` |
| 129 | + |
| 130 | +Do not report tests as passing until you have run the command and seen it pass. |
| 131 | + |
| 132 | +## Red Flags — STOP |
| 133 | + |
| 134 | +| Thought | Reality | |
| 135 | +|---------|---------| |
| 136 | +| "It's just one test, `#[test]` is fine" | Every test is `#[rstest]`. Uniform suite, zero-cost to add cases later. | |
| 137 | +| "Only two cases, not worth parametrizing" | Two cases differing only in values = one `#[rstest]` with two `#[case]`s. | |
| 138 | +| "I'll loop over the inputs" | A `for` loop in a test is parametrization done wrong. Use `#[case]`. | |
| 139 | +| "I'll copy this test and tweak the numbers" | The copy is a `#[case]` on the original, not a new function. | |
| 140 | +| "These few values prove it" | If the claim is domain-wide, use `proptest`, not cherry-picked points. | |
| 141 | +| "The unwrap warning is noise" | Add `#[allow(clippy::unwrap_used, reason = "...")]` on the module — with a reason. | |
| 142 | +| "I'll use /tmp for the file test" | `tempfile::tempdir()`. Always. | |
| 143 | + |
| 144 | +## Common Mistakes |
| 145 | + |
| 146 | +- Bare `#[test]` on a function that could take cases → make it `#[rstest]`. |
| 147 | +- Duplicated test functions differing only in literals → collapse to `#[case]`s. |
| 148 | +- Unlabeled cases (`#[case(1, "file")]`) → prefer `#[case::descriptive_name(...)]` so failures read well. |
| 149 | +- A loop asserting over a list of inputs → `#[case]` per input. |
| 150 | +- Missing the `reason = "..."` on a clippy `allow` → the lint config expects it. |
0 commit comments