원클릭으로
write-tests
Always use this skill before writing any test code in the mcpr repository
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Always use this skill before writing any test code in the mcpr repository
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
| name | write-tests |
| description | Always use this skill before writing any test code in the mcpr repository |
Load this skill when writing or editing tests anywhere in this project.
Prefer inline unit tests (#[cfg(test)]) for any module. Each module should own its tests. Choose placement by what you're testing:
| What you're testing | Where to put it |
|---|---|
| Pure functions, parsing, formatting, data transforms | Inline #[cfg(test)] in the same file |
| Module-internal logic (event routing, config resolution) | Inline #[cfg(test)] in the same file |
| Cross-module integration (proxy → handler → event bus → store) | tests/ directory (integration tests) |
| HTTP endpoint behavior (axum handlers, admin API) | Inline with axum_test or tests/ with a test server |
| CLI argument parsing and command dispatch | Inline #[cfg(test)] in config.rs or cmd/ modules |
Never test private implementation details that are likely to change. Test the contract (inputs → outputs), not the internal steps.
Each #[test] or #[tokio::test] function tests one behavior. Name it after what it verifies, not what it calls:
// Good — describes the behavior
#[test]
fn parse_threshold_rejects_empty_unit() { ... }
// Bad — describes the code path
#[test]
fn test_parse_threshold_us() { ... }
The test body should contain only what is being tested — setup, action, assertion. No logging, no comments explaining what assert_eq does, no redundant intermediate variables.
// Good
#[test]
fn format_bytes_gigabyte_range() {
assert_eq!(format_bytes(2 * 1024 * 1024 * 1024), "2.0 GB");
}
// Bad — unnecessary noise
#[test]
fn format_bytes_gigabyte_range() {
let input = 2 * 1024 * 1024 * 1024; // 2 GB in bytes
let result = format_bytes(input);
let expected = "2.0 GB";
assert_eq!(result, expected, "Should format 2GB correctly");
}
If 3+ tests share setup (opening a DB, building a config, constructing an HTTP request), extract a helper function. Name it after what it produces, not what it does:
fn seeded_engine() -> QueryEngine { ... }
fn gateway_config() -> GatewayConfig { ... }
fn mcp_post_request(body: &str) -> Request<Body> { ... }
assert_eq! for value equality — always preferred when comparing concrete valuesassert! for boolean conditions — use when the check is naturally boolean (is_empty(), contains(), comparisons)assert!(matches!(...)) or matches! guard for enum variant checks without destructuringassert!(x == y) — use assert_eq!(x, y) for better error messagesassert_ne! when you need to verify something changedEvery function or behavior worth testing should have three categories of tests:
The normal, expected usage. One or two tests that confirm the function works as designed.
#[test]
fn parse_since_valid_hours() {
let ts = parse_since("1h").unwrap();
let now = chrono::Utc::now().timestamp_millis();
assert!((now - ts - 3_600_000).abs() < 1000);
}
Inputs at the boundary of valid/invalid. Think about:
0, "", [], Nonei64::MAX, very long strings, huge byte counts#[test]
fn format_latency_boundary_us_to_ms() {
assert_eq!(format_latency(999), "999μs"); // just below
assert_eq!(format_latency(1_000), "1.00ms"); // exactly at boundary
}
Confirm the function fails gracefully with bad input — returns Err, returns a default, does not panic.
#[test]
fn parse_since_rejects_garbage() {
assert!(parse_since("bad").is_err());
assert!(parse_since("").is_err());
assert!(parse_since("abc123").is_err());
}
serde_json::to_string, don't test serde)Use #[tokio::test] for async code. Keep the runtime config minimal:
#[tokio::test]
async fn event_bus_routes_to_sinks() {
let sink = TestSink::new();
let bus = EventBus::start(vec![Box::new(sink.clone())]);
bus.emit(test_event());
bus.shutdown().await;
assert_eq!(sink.events(), vec![test_event()]);
}
For tests that need multi-threaded runtime (spawning tasks, channels):
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_proxy_requests() { ... }
Use tempfile::tempdir() for an isolated database per test. Clean up is automatic.
#[test]
fn store_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = Store::open(StoreConfig { db_path, .. });
// ... test against the store
}
Use tempfile::tempdir() for any test that reads/writes files. Never write to the real ~/.mcpr/.
For axum handler tests, build the router and use axum::body::to_bytes or tower's ServiceExt::oneshot:
#[tokio::test]
async fn health_endpoint_returns_ok() {
let app = build_app(test_app_state());
let resp = app
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[cfg(test)]
mod tests {
use super::*;
// ── Helpers (if needed) ───────────────────────────────────
fn sample_log_row() -> LogRow { ... }
// ── Tests grouped by function/behavior ────────────────────
// parse_since
#[test]
fn parse_since_valid() { ... }
#[test]
fn parse_since_rejects_empty() { ... }
// parse_threshold_us
#[test]
fn parse_threshold_microseconds() { ... }
#[test]
fn parse_threshold_milliseconds() { ... }
#[test]
fn parse_threshold_rejects_bare_unit() { ... }
}
Group tests by the function or behavior they cover. Separate groups with a blank line. No need for nested modules unless the file has 50+ tests.
Use the pattern [behavior]__[case] — double underscore separates what from when/how:
| Category | Example |
|---|---|
| Happy path | parse_since__valid_hours |
| Edge case | format_latency__boundary_us_to_ms |
| Error case | parse_threshold__rejects_empty_unit |
| Zero/empty | format_bytes_col__none_returns_dash |
| Round-trip | pid_file__roundtrip |
| Async behavior | event_bus__flushes_on_shutdown |
| Negative | config__missing_mcp_is_error |
Naming rules:
test_ prefix (the #[test] attribute is enough)__: the function or concept being tested__: the specific scenario or conditionparse_since__rejects_empty_string not parse_since__bad_inputUse #[cfg(unix)] for tests that depend on Unix features (signals, fork, PID files):
#[cfg(unix)]
#[test]
fn process_alive_detects_self() {
assert!(is_process_alive(std::process::id()));
}
Do not write platform-specific tests for Windows unless the code has Windows-specific paths.
Target: at least 70% line coverage per module. When writing tests for a module, aim to cover every meaningful code path — not just the happy path.
Use cargo-llvm-cov (install once: cargo install cargo-llvm-cov):
# Coverage for a single crate (summary)
cargo llvm-cov -p mcpr --lib
# Coverage with per-file detail
cargo llvm-cov -p mcpr --lib --text
# HTML report (opens in browser)
cargo llvm-cov -p mcpr --lib --html --open
# Coverage for a specific module (filter by file)
cargo llvm-cov -p mcpr --lib --text | grep render.rs
Err or exit#[cfg(not(unix))] on macOS), panic-only branches like unreachable!(), trivial Display/Debug implsBefore finishing a test pass, check coverage. If a module is below 70%:
--text or --htmlWhen checking coverage in CI or before a PR, use the --fail-under flag:
cargo llvm-cov -p mcpr --lib --fail-under-lines 70
# All tests in one crate
cargo test -p mcpr
# A specific test
cargo test -p mcpr parse_since
# With output (for debugging)
cargo test -p mcpr -- --nocapture
# All workspace tests
cargo test --workspace