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 test inside the dev container (tools/devtool gives you a shell, or it wraps cargo). The toolchain is pinned by rust-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 test in 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:

CandidateWhereWhy it's testable in isolation
TokenBucket::new edge casessrc/vmm/src/rate_limiter/mod.rsPure math; clear valid/invalid inputs
A machine/drive/net validatorsrc/vmm/src/vmm_config/Returns Result/Err on bad config
A small parsing helperrg "fn .*parse|TryFrom|FromStr" src/vmm/srcDeterministic input→output mapping
A BucketReduction outcome pathsrc/vmm/src/rate_limiter/mod.rsEnum 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() for Option/Result outcomes.
  • For private fields, the test module sometimes adds a small impl to 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:

PropertyWhy it matters
Covers the boundary (size == 0, refill_ms == 0)That's where validators are wrong
Asserts both the Option outcome and the constructed stateCatches a constructor that accepts but mis-initializes
Each case has a label in the failure messageA red case tells you which input broke, instantly
No I/O, no sleep, no global stateDeterministic, parallel-safe, sub-millisecond
Uses real public types from the crateIt 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 vmm crate 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 warnings are 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

  1. Add a proptest/property-style assertion (if the crate uses it — rg -n "proptest\|quickcheck" src/vmm) that a valid bucket's budget() never exceeds capacity().
  2. Pick a validator in src/vmm/src/vmm_config/ and write the analogous table-driven test for its Err paths, asserting the exact error variant with assert_matches!.
  3. 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).
  4. 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:

  1. Why does this logic belong in a Rust unit test rather than a pytest integration test?
  2. What two things must a good unit test do beyond passing against correct code?
  3. What does the mutation check in Step 5 prove, and why is it not optional?
  4. Why is a table-driven test with per-case labels better than seven separate #[test] functions?
  5. What is clippy's setting in CI, and what happens to your PR if a lint warns?
  6. Where does the #[cfg(test)] mod tests block live relative to the code it tests, and what does use super::*; do?
  7. 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.