Level 5: Testing and Debugging
Up to here you have read the engine and made small, surgical changes. From this level on, the rule hardens: no change ships without a test, and a Firecracker maintainer's first question on almost every pull request is "where's the test, and is it deterministic?" CONTRIBUTING is explicit — new functionality requires integration tests, and you may not lower unit-test coverage. This level makes Firecracker's test stack your home so that you can answer both questions before anyone asks.
Firecracker's test stack has two distinct halves that you must stop conflating. The first is Rust
unit tests — #[cfg(test)] modules compiled into each crate and run with cargo test, testing
pure logic (rate-limiter token math, config validation, parsing, serialization round-trips) with no
microVM in sight. The second is a Python pytest integration suite under tests/, driven by
tools/devtool test, that builds the real firecracker binary, spawns it as a real process,
configures it over the real REST API, boots a real guest, and SSHes in to assert end-to-end
behavior. Both run in CI on x86_64 and aarch64. On top of those sit the style/build gates
(tools/devtool fmt, checkstyle, checkbuild --all) and Kani formal-verification proofs for a
handful of unsafe-adjacent invariants.
This is the level where you learn how a mature, security-critical project defends itself. By the
end you can run a single test of either kind, read its failure, drive a real microVM under a
debugger, and reach for the cheapest test that still proves your change. This curriculum will not
hold your hand here either: it points you at tests/framework/, gives you the questions, and makes
you run every command.
Note: The integration suite is the load-bearing half.
tools/devtool testruns pytest, notcargo test. The Rust unit tests arecargo test, but the suite that gates your PR — the one that boots real microVMs — is Python. Internalize this split now; mixing them up wastes hours.
Learning Objectives
By the end of Level 5 you must be able to:
- Explain Firecracker's two-layer test stack — Rust
#[cfg(test)]unit tests vs the pytest integration suite — and choose the right layer for a given change. - Run a single Rust unit test (
cargo test, scoped to one crate/module/function) and a single pytest integration test (tools/devtool test -- <path>::<name>), and read each failure correctly. - Describe the pytest framework: the
Microvmhelper, themicrovm_factoryfixture, theguest_kernel/rootfsartifact fixtures, and theuvm/uvm_plainconvenience fixtures. - Trace exactly what an integration test does to a running
firecracker: spawn the process, configure over the Unix socket,InstanceStart, SSH into the guest, assert, tear down. - Write a focused, table-driven Rust unit test for an under-covered function and run it without spinning up the whole suite.
- Write a new pytest that boots a microVM and asserts an end-to-end behavior (a second drive, a NIC,
a metrics field), using the
Microvmhelper and SSH-into-guest utilities. - Debug a misbehaving microVM with the tools that actually work here: the Firecracker log and
metrics, the serial console, the API socket,
strace, andgdbagainst a vCPU thread. - Reproduce a flaky test deterministically, identify the race/timing/ordering assumption, and fix it
without
time.sleep-as-synchronization.
The Two-Layer Test Stack
Firecracker tests at two altitudes. Picking the lower one whenever it can prove your change is the single most valuable testing skill — unit tests run in milliseconds; an integration test builds a binary, boots a kernel, and SSHes into a guest.
What does your change touch?
│
┌────────────────────────────┴────────────────────────────┐
│ │
pure logic in one crate observable microVM behavior
(rate-limiter math, a config (a drive appears as /dev/vdb,
validator, a parser, a a NIC gets configured, a
serialization round-trip) metric updates, an API
│ error is returned, a boot
▼ succeeds/fails)
┌──────────────────┐ │
│ Rust unit test │ ▼
│ #[cfg(test)] mod │ ┌──────────────────────────────┐
│ run: cargo test │ │ pytest integration test │
└──────────────────┘ │ tests/integration_tests/... │
(ms; no VM) │ run: tools/devtool test │
│ (builds binary, boots a guest)│
└──────────────────────────────┘
flowchart TD
Q{What are you proving?} --> U[Pure logic in one crate]
Q --> I[Observable microVM behavior]
Q --> S[Wire/serialization round-trip]
Q --> P[Formal invariant on unsafe-adjacent code]
U --> UT["Rust #[cfg(test)] unit test<br/>cargo test -p vmm module::test"]
S --> UT
I --> IT["pytest in tests/integration_tests/<br/>tools/devtool test -- path::name"]
P --> K["Kani proof harness<br/>cargo kani / labeled 'Kani'"]
| Layer | Lives in | Run with | Proves | Cost |
|---|---|---|---|---|
| Rust unit test | #[cfg(test)] mod tests in each src/**/*.rs | cargo test (in the dev container) | Pure logic, parsing, math, serialization | milliseconds |
| pytest integration | tests/integration_tests/{functional,performance,security,build}/ | tools/devtool test [-- args] | End-to-end microVM behavior over the real API | seconds–minutes |
| Style/build gates | tools/ + rustfmt/clippy/black config | tools/devtool fmt / checkstyle / checkbuild --all | Formatting, lints (warnings-as-errors), build matrix | seconds–minutes |
| Kani proofs | proof harnesses in src/ (labeled Kani) | cargo kani (CI; verify on your branch) | Formal invariants (e.g. virtio/queue arithmetic) | minutes |
Tip: Clippy is warnings-as-errors in CI:
cargo clippy --all --all-targets --all-features -- -D warnings. A greencargo testthat fails clippy still fails the PR. Runtools/devtool checkstylebefore you push.
The pytest Integration Framework, From Orbit
The integration suite is a normal pytest project with a heavyweight fixture layer that manufactures real microVMs. You will dissect it in Lab 5.1; here is the map so the labs have something concrete underneath.
# Confirm the framework exists and see its shape — run this, don't trust the prose.
find tests -maxdepth 2 -type d | sort
ls tests/framework/
rg -n "class Microvm" tests/framework/microvm.py
rg -n "def microvm_factory|def uvm|def guest_kernel|def rootfs" tests/conftest.py
tests/
├── conftest.py ← session/test fixtures: microvm_factory, guest_kernel, rootfs, uvm…
├── pytest.ini ← markers, options
├── pyproject.toml ← deps / config
├── framework/ ← the framework package (the helper layer)
│ ├── microvm.py ← the Microvm class (THE central helper)
│ ├── microvm_helpers.py ← guest-side helpers (verify on your branch)
│ ├── artifacts.py ← kernels / rootfs / CI artifacts
│ ├── jailer.py ← JailerContext (run firecracker under the jailer)
│ ├── http_api.py / *_api ← the Api wrapper around the REST socket (verify name)
│ ├── utils.py, utils_*.py ← ssh, networking, cpu templates, …
│ └── ab_test.py ← A/B (before/after) performance testing
├── host_tools/ ← C/Rust guest-side test binaries, network helpers
└── integration_tests/
├── functional/ ← test_drive_virtio.py, test_net.py, test_api.py, test_metrics.py…
├── performance/ ← test_boottime.py, test_snapshot.py, test_huge_pages.py
├── security/ ← test_vulnerabilities.py, test_sec_audit.py
└── build/ ← test_sanitizers.py, build-matrix checks
The central abstraction is the Microvm class in tests/framework/microvm.py. A test does not
talk to curl and a socket directly — it asks the microvm_factory fixture for a Microvm, then
drives it through Python methods that wrap the REST API:
Microvm member | What it does | Maps to (fact sheet C3/C4) |
|---|---|---|
spawn() | Launch the firecracker process (often jailed), open the API socket | firecracker --api-sock … |
basic_config(...) | One call to set boot-source + a root drive + machine-config | PUT /boot-source, /drives, /machine-config |
add_drive(...) | Attach an extra block device | PUT /drives/{id} |
add_net_iface(...) | Create a TAP and attach a NIC | PUT /network-interfaces/{id} |
start() | Boot the guest | PUT /actions {InstanceStart} |
api | Typed wrapper around the REST socket (.boot_source, .drive, .actions, …) | the whole API surface |
ssh / ssh_iface(i) | Cached SSH connection into the booted guest | guest-side assertions |
flush_metrics() | Flush + parse the metrics JSON | PUT /actions {FlushMetrics} |
serial / serial_input(...) | Drive the serial console (when there is no network) | 16550 UART console |
kill() | Tear down the process and clean up | process teardown |
Warning: Never hard-code paths, ports, or socket names in a test. The framework hands you a unique session root, a TAP per interface, and a fresh socket. Reusing a name across tests is the classic source of the flake you will fix in Lab 5.4.
Artifacts: Where Kernels and Rootfs Come From
An integration test needs a guest kernel and a root filesystem. The framework supplies them as fixtures so a test never downloads or builds them inline:
# How tests get a kernel + rootfs — read the fixtures, then the artifact loader.
rg -n "def guest_kernel|def rootfs|ALL_GUEST_KERNELS|artifact_dir" tests/conftest.py tests/framework/artifacts.py
| Fixture | Yields | Notes |
|---|---|---|
guest_kernel | Path to a guest vmlinux/Image, parametrized over ALL_GUEST_KERNELS | One test → many kernel versions (verify the set on your branch) |
rootfs | Path to a rootfs disk matching the kernel and rootfs_mode | squashfs (ro) or ext4 (rw) |
microvm_factory | A factory that builds/spawns Microvms and reaps them on failure | The entry point most tests use |
uvm_plain / uvm | A pre-built (sometimes pre-configured/booted) Microvm | Convenience for the common case |
io_engine, vcpu_count, mem_size_mib, huge_pages, pci_enabled | Parametrized config knobs | Indirectly override defaults per test |
Because guest_kernel and several config fixtures are parametrized, one def test_x(...)
silently becomes many test cases — one per kernel, per io-engine, per snapshot type. That is
deliberate breadth, and it is also a source of flakiness when a test makes an assumption that holds
on one kernel but not another.
Required Reading
Read these before Lab 5.1. Confirm each exists on your checkout with the command shown; do not trust
a path you have not ls'd.
| Source | Confirm it exists | What to extract |
|---|---|---|
tests/README.md | ls tests/README.md | How the suite is organized; how to run a subset; markers |
tests/framework/microvm.py | rg -n "class Microvm" tests/framework/microvm.py | The full Microvm lifecycle and every helper you'll call |
tests/conftest.py | rg -n "@pytest.fixture" tests/conftest.py | head -40 | The fixtures: microvm_factory, guest_kernel, rootfs, uvm* |
tests/framework/artifacts.py | ls tests/framework/artifacts.py | How kernels/rootfs are discovered and parametrized |
| A representative functional test | sed -n '1,80p' tests/integration_tests/functional/test_metrics.py | The real spawn → config → start → ssh → assert shape |
tests/integration_tests/functional/test_drive_virtio.py | ls tests/integration_tests/functional/test_drive_virtio.py | The pattern you will copy in Lab 5.3 (a second drive) |
docs/ test/devtool notes + CONTRIBUTING.md | rg -n "devtool test|integration test" CONTRIBUTING.md docs/*.md | The contribution requirement: tests are mandatory |
A Rust #[cfg(test)] module | rg -n "mod tests" src/vmm/src/rate_limiter/mod.rs | Idiomatic Firecracker unit-test style |
# One command to confirm the whole reading list resolves on your branch:
ls tests/README.md tests/framework/microvm.py tests/conftest.py tests/framework/artifacts.py \
tests/integration_tests/functional/test_metrics.py \
tests/integration_tests/functional/test_drive_virtio.py CONTRIBUTING.md
Source Code Areas to Inspect
| Area | Path | Why |
|---|---|---|
| The framework package | tests/framework/ | The Microvm helper, jailer wrapper, artifacts, ssh/net utils |
| The fixtures | tests/conftest.py | Everything a test receives for free; parametrization |
| Functional tests | tests/integration_tests/functional/ | The models for Labs 5.1 and 5.3 |
| Unit tests (logic) | #[cfg(test)] modules across src/vmm/src/ | The model for Lab 5.2; rate limiter, vmm_config, devices |
| Rate limiter (Lab 5.2 candidate) | src/vmm/src/rate_limiter/ | TokenBucket/RateLimiter math — easy to extend coverage |
| Config validation (Lab 5.2 candidate) | src/vmm/src/vmm_config/ | Validators that reject bad machine/drive/net config |
| Devtool test runner | tools/devtool | The single entry point: tools/devtool test -- <pytest args> |
| Logging & metrics | src/vmm/src/logger/ | What a FlushMetrics/log line looks like when you debug |
# Where the unit-testable logic clusters — run these to find #[cfg(test)] density.
rg -l "#\[cfg\(test\)\]" src/vmm/src | head -40
rg -c "#\[test\]" src/vmm/src/rate_limiter/mod.rs src/vmm/src/vmm_config/*.rs 2>/dev/null
Key Types and Fixtures Quick Reference
Always locate these with the command shown — paths drift between branches.
| Name | Kind | Locate it |
|---|---|---|
Microvm | pytest helper class | rg -n "class Microvm" tests/framework/microvm.py |
MicroVMFactory | factory behind microvm_factory | rg -n "class MicroVMFactory|def microvm_factory" tests/framework tests/conftest.py |
Api (REST wrapper) | typed socket client | rg -n "class Api\b|self.api =" tests/framework |
JailerContext | jailer config for a test | rg -n "class JailerContext" tests/framework/jailer.py |
guest_kernel / rootfs | artifact fixtures | rg -n "def guest_kernel|def rootfs" tests/conftest.py |
microvm_factory / uvm_plain | lifecycle fixtures | rg -n "def microvm_factory|def uvm_plain|def uvm\b" tests/conftest.py |
TokenBucket / RateLimiter | Rust types (unit-test target) | rg -n "struct TokenBucket|struct RateLimiter" src/vmm/src/rate_limiter/mod.rs |
#[cfg(test)] mod tests | Rust unit-test module | rg -n "mod tests" src/vmm/src/rate_limiter/mod.rs |
GitHub Issue Categories for Level 5
These are issue classes a Level 5 graduate can credibly take — see Issue Roadmap Stage 1 and Stage 9.
| Category | Example | Label hints |
|---|---|---|
| Test coverage gap | "Add unit tests for X validator" | good first issue, Type: Enhancement |
| Flaky integration test | "test_foo intermittently fails on aarch64 CI" | Status: Awaiting review, flaky |
| Missing integration test | "New API field has no integration coverage" | Type: Bug/Enhancement |
| Test-only refactor | "De-duplicate the drive-setup helper in tests" | good first issue |
| Debuggability | "Improve a confusing test failure message" | Type: Enhancement |
Deliverables
You must demonstrate all of the following before advancing to Level 6:
- You can run a single Rust unit test and a single pytest integration test by name, and read each failure (Lab 5.1).
-
You traced exactly what one existing integration test does to a running
firecracker— every API call, the boot, the SSH assertion, the teardown (Lab 5.1). -
A focused, table-driven Rust
#[test]you wrote for a previously under-covered function, run withcargo test, that fails when you break the function (Lab 5.2). -
A new pytest that boots a microVM and asserts an end-to-end behavior (a second drive as
/dev/vdb, or a NIC, or a metrics field), passing undertools/devtool test(Lab 5.3). - A reproduced, diagnosed, and correctly fixed flaky test — made deterministic, with the race explained (Lab 5.4).
-
From memory: the two-layer stack, how to run one test of each kind, and why
time.sleepis not synchronization.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Running cargo test and assuming the PR is covered | The integration suite — the gate — never ran | tools/devtool test runs the pytest suite; run both |
| Using an integration test for pure logic | Minutes-long, flaky CI for something a unit test proves in ms | Push the assertion down to a Rust #[cfg(test)] |
time.sleep(2) to "wait for boot" | Flaky under load; slow always | Wait on a real signal: SSH reachable, a log line, an api poll |
| Hard-coding socket/TAP/file names | Cross-test clashes; order-dependent failures | Use the factory's unique paths/namespaces |
| Editing a test until it stops failing | Hides the bug the test found | Fix the production code or the bad assumption |
| Ignoring the parametrization | "Passes for me" on one kernel; fails another in CI | Run the full parametrized set, or pin and explain |
Skipping tools/devtool fmt/checkstyle | Green tests, red CI (clippy = warnings-as-errors) | Run the gates locally before pushing |
| Not reading the captured log/metrics on failure | You chase the symptom, not the cause | Read the Firecracker log, metrics, and serial output first |
How to Verify Success
# 1. Run a single Rust unit test, scoped tight.
tools/devtool test -- --help # see how the runner forwards args (then:)
# Inside the dev container you can also: cargo test -p vmm rate_limiter::tests::test_token_bucket_create
# 2. Run a single integration test by node id.
tools/devtool test -- integration_tests/functional/test_metrics.py -k metrics -v
# 3. Run only the functional suite, stop on first failure.
tools/devtool test -- integration_tests/functional/ -x
# 4. Reproduce a specific parametrized case and keep its artifacts.
tools/devtool test -- "integration_tests/functional/test_drive_virtio.py::test_partuuid_boot" -v
# 5. The style gate the maintainers run on every PR.
tools/devtool checkstyle
When you can scope a test of either kind, read its failure from the captured log/metrics, write a new test at the right altitude, and make a flaky one deterministic, you are ready for Level 6.
PR Profile: Level 5 Graduate
A Level 5 graduate can credibly open these pull requests:
| PR type | What it looks like | Why a maintainer trusts it |
|---|---|---|
| Add unit-test coverage | Table-driven #[test]s for an under-tested validator/calculation | Demonstrates the function's contract; raises coverage |
| Add an integration test | A focused pytest for an API/device behavior with no coverage | Proves observable behavior end-to-end; mandatory for new features |
| Fix a flaky test | A diff that removes a sleep/race and waits on a real signal | Shows you found the root cause, not just muted the symptom |
| Improve a failure message | Clearer assert / log on a confusing failure | Saves every future debugger time |
| Test refactor | De-duplicate or generalize a test helper in tests/framework/ | Lowers the cost of every future test |
A Level 5 graduate writes the test first, runs it red, makes it green, and runs the style gate before pushing — exactly the workflow the maintainers expect.