Lab 5.2: Add a Missing Unit Test
Background
The integration suite proves behavior; Rust unit tests prove logic. When you can isolate a
function — a rate-limiter calculation, a config validator, a parsing helper — and pin its contract in
a #[cfg(test)] module, you do it there, because that test runs in milliseconds, fails precisely on
the line that's wrong, and never flakes. CONTRIBUTING's rule "do not lower unit-test coverage" exists
because these tests are cheap insurance the maintainers depend on.
This is a build-it lab. You will find a function in the vmm crate with thin coverage, study how
Firecracker writes its unit tests, write a focused table-driven #[test] that exercises the
function's edge cases, run it with cargo test, and confirm it fails when you break the function. The
goal is not just "a passing test" — it is a test that would have caught a real bug.
Note: Run
cargo testinside the dev container (tools/devtoolgives you a shell, or it wrapscargo). The toolchain is pinned byrust-toolchain.toml; use the container's, not a system-wide rustc.
Why This Lab Matters for Contributors
- "Add unit tests for X" is one of the most accessible real issues (see Issue Roadmap Stage 1) and a clean way to earn your first merges while learning a subsystem deeply.
- A table-driven test is the idiom maintainers expect; matching house style makes your PR easy to approve.
- You learn a function's contract by writing its tests — including the edge cases the author may have missed, which is exactly how coverage finds bugs.
- This skill underpins every other PR: a feature PR's unit tests are written exactly like this.
Prerequisites
- Completed Lab 5.1 and the earlier levels.
- A built checkout; you can run
cargo testin the dev container.
# Confirm cargo test runs at all (scope to one crate so it's fast).
tools/devtool test -- --help # see how to forward args
# then, in the container shell:
cargo test -p vmm rate_limiter:: -- --nocapture 2>&1 | tail -20
Step 1: Find a thinly-covered function
Coverage gaps cluster around small, pure helpers: validators, math, parsers. Hunt for functions whose
module has few or no #[test]s.
# Modules with logic but light test density — candidates.
for f in $(rg -l "pub fn|fn .*-> Result\|fn .*-> Option" src/vmm/src/vmm_config src/vmm/src/rate_limiter 2>/dev/null); do
printf "%-60s tests=%s\n" "$f" "$(rg -c '#\[test\]' "$f" 2>/dev/null || echo 0)"
done | sort -t= -k2 -n
# Validators are a rich seam: functions that reject bad config.
rg -n "fn .*validate|fn .*check|return Err\(" src/vmm/src/vmm_config/ | head -30
# Rate-limiter math is small, pure, and easy to extend.
rg -n "pub fn|fn reduce|fn budget|fn capacity|TokenBucket::new" src/vmm/src/rate_limiter/mod.rs
Good candidate shapes:
| Candidate | Where | Why it's testable in isolation |
|---|---|---|
TokenBucket::new edge cases | src/vmm/src/rate_limiter/mod.rs | Pure math; clear valid/invalid inputs |
| A machine/drive/net validator | src/vmm/src/vmm_config/ | Returns Result/Err on bad config |
| A small parsing helper | rg "fn .*parse|TryFrom|FromStr" src/vmm/src | Deterministic input→output mapping |
A BucketReduction outcome path | src/vmm/src/rate_limiter/mod.rs | Enum result you can assert exactly |
Pick one whose contract you can state in a sentence. For this lab the worked example is a
TokenBucket-style validator: "new(size, one_time_burst, refill_ms) returns Some for valid
inputs and None when size == 0 or refill_ms == 0." Confirm the real signature and outcomes on
your branch before you assert on them:
rg -n "fn new\(|-> Option<Self>|-> io::Result|is_none\(\)|BucketReduction" src/vmm/src/rate_limiter/mod.rs
Tip: Don't pick a function that needs a
Vmm, a vCPU, a socket, or a guest. If your test would need to boot anything, it belongs in the integration suite (Lab 5.3), not here.
Step 2: Read how Firecracker already writes unit tests
Copy the house style; don't invent one. Find the #[cfg(test)] module in the file you're testing and
in a couple of neighbors.
# The test module structure to imitate.
rg -n "mod tests|#\[cfg\(test\)\]|#\[test\]|fn test_" src/vmm/src/rate_limiter/mod.rs | head -40
# How existing tests construct and assert (study these verbatim).
sed -n '/mod tests/,$p' src/vmm/src/rate_limiter/mod.rs | sed -n '1,80p'
You'll see the conventions:
- A single
#[cfg(test)]\nmod tests { use super::*; ... }block at the end of the file. - Each test is
#[test] fn test_<thing>()with a descriptive name. - Assertions use
assert!,assert_eq!,assert_matches!, and.unwrap()/.is_none()forOption/Resultoutcomes. - For private fields, the test module sometimes adds a small
implto expose accessors — note how.
A representative existing test (verify on your branch — yours may differ):
#![allow(unused)] fn main() { #[test] fn test_token_bucket_create() { let tb = TokenBucket::new(1000, 0, 1000).unwrap(); assert_eq!(tb.capacity(), 1000); assert_eq!(tb.budget(), 1000); // Invalid configs return None. assert!(TokenBucket::new(0, 1234, 1000).is_none()); } }
Step 3: Write a table-driven test
Table-driven is the idiom: one list of (input, expected) cases, one loop, one assertion that prints
which case failed. It scales to a dozen edge cases without a dozen functions and makes the contract
legible.
Add this inside the existing #[cfg(test)] mod tests { ... } block (adapt the function/types to the
one you chose; the shape is the point):
#![allow(unused)] fn main() { #[test] fn test_token_bucket_new_validation() { // (size, one_time_burst, refill_ms, expect_some, label) let cases: &[(u64, u64, u64, bool, &str)] = &[ (1000, 0, 1000, true, "ordinary valid bucket"), (1, 0, 1, true, "minimal valid bucket"), (u64::MAX, 0, 1000, true, "max size"), (1000, 500, 1000, true, "with one-time burst"), (0, 0, 1000, false, "zero size is invalid"), (1000, 0, 0, false, "zero refill time is invalid"), (0, 1234, 0, false, "both invalid"), ]; for (size, burst, refill_ms, expect_some, label) in cases.iter().copied() { let got = TokenBucket::new(size, burst, refill_ms); assert_eq!( got.is_some(), expect_some, "case '{label}': TokenBucket::new({size}, {burst}, {refill_ms}) \ expected is_some={expect_some}, got {:?}", got.is_some() ); // For valid cases, assert the contract on the constructed value, too. if let Some(tb) = got { assert_eq!(tb.capacity(), size, "case '{label}': capacity mismatch"); assert_eq!(tb.budget(), size, "case '{label}': initial budget should equal size"); } } } }
What makes this a good test, not just a passing one:
| Property | Why it matters |
|---|---|
Covers the boundary (size == 0, refill_ms == 0) | That's where validators are wrong |
Asserts both the Option outcome and the constructed state | Catches a constructor that accepts but mis-initializes |
| Each case has a label in the failure message | A red case tells you which input broke, instantly |
| No I/O, no sleep, no global state | Deterministic, parallel-safe, sub-millisecond |
| Uses real public types from the crate | It tests the actual contract, not a mock |
Warning: Do not assert on private internals you reached via a test-only accessor unless the existing tests already do. Prefer the public contract — it's what callers rely on and what a refactor must preserve.
Step 4: Run it, scoped tight
# Just your test, in just the vmm crate, with output shown.
cargo test -p vmm rate_limiter::tests::test_token_bucket_new_validation -- --nocapture
# The whole module's tests (your new one + the existing ones).
cargo test -p vmm rate_limiter::
# Through devtool (what CI does) — forwards to the right runner.
tools/devtool test -- --help # confirm how unit tests are invoked on your branch
Expected:
running 1 test
test rate_limiter::tests::test_token_bucket_new_validation ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Step 5: Prove the test has teeth (mutation check)
A test that passes against correct code proves nothing until it fails against broken code. Break the function deliberately, confirm your test catches it, then revert.
# Find the guard you're testing.
rg -n "fn new\(" src/vmm/src/rate_limiter/mod.rs
Temporarily weaken the validation — e.g. delete the size == 0 rejection so the constructor wrongly
returns Some for a zero-size bucket — then:
cargo test -p vmm rate_limiter::tests::test_token_bucket_new_validation -- --nocapture
You must see your "zero size is invalid" case fail with the labelled message. If it still passes,
your test isn't actually exercising that path — fix the test, not the code. Then revert the
mutation (git diff, git checkout -- src/vmm/src/rate_limiter/mod.rs) so only your test remains.
flowchart LR
A[Pick a pure function] --> B[Read existing #[cfg(test)] style]
B --> C[Write table-driven #[test]]
C --> D[cargo test -> green]
D --> E[Mutate the function]
E --> F{Test goes red?}
F -- yes --> G[Revert mutation; keep test]
F -- no --> C
G --> H[fmt + clippy + commit -s]
Step 6: Pass the gates and prepare the change
# Format + lint exactly as CI does (clippy is warnings-as-errors).
tools/devtool fmt
cargo clippy -p vmm --all-targets -- -D warnings
# Full local style gate.
tools/devtool checkstyle
This is a coverage-only change, so commit it cleanly with DCO sign-off and, if the repo convention
calls for it, no CHANGELOG entry is needed for a test-only addition (verify against CHANGELOG.md
conventions):
git checkout -b test/rate-limiter-token-bucket-validation
git add src/vmm/src/rate_limiter/mod.rs
git commit -s -m "test: cover TokenBucket::new input validation"
Deliverables
-
You located an under-covered pure function in the
vmmcrate and stated its contract in one sentence. -
You read the existing
#[cfg(test)]style in that file and matched it. -
A table-driven
#[test]with labelled cases covering the boundary conditions. -
cargo test -p vmm <module>::tests::<name>passes. - You mutated the function, watched your test go red on the right case, and reverted.
-
tools/devtool fmt+cargo clippy -- -D warningsare clean; the commit is signed off.
Troubleshooting
cargo test rebuilds the world / is slow the first time
The first compile in the container is cold. Scope to -p vmm and a module path; subsequent runs are
incremental. Don't run the whole workspace's tests to check one function.
error[E0599]: no method named capacity
You asserted on a method that doesn't exist or is private on your branch. rg -n "pub fn|fn capacity|fn budget" src/vmm/src/rate_limiter/mod.rs and assert only on what's accessible.
clippy fails on your test
Common: .iter().copied() vs .iter().cloned(), needless format!, or an unused binding. Run
cargo clippy -p vmm --all-targets -- -D warnings and fix every warning — CI rejects all of them.
The mutation didn't make the test fail
Your case isn't hitting that path. Add a case at the exact boundary the guard checks, or print
got to confirm what the constructor actually returns.
Expected Output
$ cargo test -p vmm rate_limiter::tests::test_token_bucket_new_validation
Compiling vmm v...
running 1 test
test rate_limiter::tests::test_token_bucket_new_validation ... ok
test result: ok. 1 passed; 0 failed; ...
Stretch Goals
- Add a
proptest/property-style assertion (if the crate uses it —rg -n "proptest\|quickcheck" src/vmm) that a valid bucket'sbudget()never exceedscapacity(). - Pick a validator in
src/vmm/src/vmm_config/and write the analogous table-driven test for itsErrpaths, asserting the exact error variant withassert_matches!. - Run the crate's coverage tooling (if present) before/after and show the line you added (
rg -n "tarpaulin\|llvm-cov\|coverage" tools/ Cargo.toml). - Find a function where a
#[test]exists but misses a boundary, and add the missing case (the highest-value kind of coverage PR).
Validation / Self-check
Answer without notes; these gate completion:
- Why does this logic belong in a Rust unit test rather than a pytest integration test?
- What two things must a good unit test do beyond passing against correct code?
- What does the mutation check in Step 5 prove, and why is it not optional?
- Why is a table-driven test with per-case labels better than seven separate
#[test]functions? - What is clippy's setting in CI, and what happens to your PR if a lint warns?
- Where does the
#[cfg(test)] mod testsblock live relative to the code it tests, and what doesuse super::*;do? - Why must you assert on the constructed value's state, not only on
is_some()?
Next: Lab 5.3 — Build It: A Multi-Step Integration Test, where you climb back up to the pytest layer and prove an end-to-end behavior on a real microVM.