Skip to content

Commit 10c72bf

Browse files
committed
chore: Test-writing skill
1 parent 966246c commit 10c72bf

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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.

‎CLAUDE.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ Run `cargo build` from the workspace root.
1111

1212
For cross-cutting doctrine see [PRINCIPLES.md](../PRINCIPLES.md).
1313

14+
## Testing
15+
16+
When writing or running any Rust test, follow the
17+
[`writing-rust-tests`](.claude/skills/writing-rust-tests/SKILL.md) skill:
18+
`#[rstest]` over bare `#[test]`, parametrized `#[case]` over duplicated test
19+
functions and hand-rolled loops, `proptest` for domain-wide properties. Run with
20+
plain `cargo test -p <crate>` (no nextest/just wrapper).
21+
1422
## DSL
1523

1624
The `harmont` Python package (pipeline DSL) lives inside `crates/hm-dsl-engine/harmont-py/` so it ships with the crate.

0 commit comments

Comments
 (0)