๐Ÿฆ€/๐Ÿ—๏ธ/4. Code Coverage โ€” Seeing What Tests Miss

Code Coverage โ€” Seeing What Tests Miss ๐ŸŸข

What you'll learn:

  • Source-based coverage with cargo-llvm-cov (the most accurate Rust coverage tool)
  • Quick coverage checks with cargo-tarpaulin and Mozilla's grcov
  • Setting up coverage gates in CI with Codecov and Coveralls
  • A coverage-guided testing strategy that prioritizes high-risk blind spots

Cross-references: Miri and Sanitizers โ€” coverage finds untested code, Miri finds UB in tested code ยท Benchmarking โ€” coverage shows what's tested, benchmarks show what's fast ยท CI/CD Pipeline โ€” coverage gate in the pipeline

Code coverage measures which lines, branches, or functions your tests actually execute. It doesn't prove correctness (a covered line can still have bugs), but it reliably reveals blind spots โ€” code paths that no test exercises at all.

With 1,006 tests across many crates, the project has substantial test investment. Coverage analysis answers: "Is that investment reaching the code that matters?"

Source-Based Coverage with llvm-cov

Rust uses LLVM, which provides source-based coverage instrumentation โ€” the most accurate coverage method available. The recommended tool is cargo-llvm-cov:

# Install
cargo install cargo-llvm-cov

# Or via rustup component (for the raw llvm tools)
rustup component add llvm-tools-preview

Basic usage:

# Run tests and show per-file coverage summary
cargo llvm-cov

# Generate HTML report (browsable, line-by-line highlighting)
cargo llvm-cov --html
# Output: target/llvm-cov/html/index.html

# Generate LCOV format (for CI integrations)
cargo llvm-cov --lcov --output-path lcov.info

# Workspace-wide coverage (all crates)
cargo llvm-cov --workspace

# Include only specific packages
cargo llvm-cov --package accel_diag --package topology_lib

# Coverage including doc tests
cargo llvm-cov --doctests

Reading the HTML report:

target/llvm-cov/html/index.html
โ”œโ”€โ”€ Filename          โ”‚ Function โ”‚ Line   โ”‚ Branch โ”‚ Region
โ”œโ”€ accel_diag/src/lib.rs โ”‚  78.5%  โ”‚ 82.3% โ”‚ 61.2% โ”‚  74.1%
โ”œโ”€ sel_mgr/src/parse.rsโ”‚  95.2%  โ”‚ 96.8% โ”‚ 88.0% โ”‚  93.5%
โ”œโ”€ topology_lib/src/.. โ”‚  91.0%  โ”‚ 93.4% โ”‚ 79.5% โ”‚  89.2%
โ””โ”€ ...

Green = covered    Red = not covered    Yellow = partially covered (branch)

Coverage types explained:

TypeWhat It MeasuresSignificance
Line coverageWhich source lines were executedBasic "was this code reached?"
Branch coverageWhich if/match arms were takenCatches untested conditions
Function coverageWhich functions were calledFinds dead code
Region coverageWhich code regions (sub-expressions) were hitMost granular

cargo-tarpaulin โ€” The Quick Path

cargo-tarpaulin is a Linux-specific coverage tool that's simpler to set up (no LLVM components needed):

# Install
cargo install cargo-tarpaulin

# Basic coverage report
cargo tarpaulin

# HTML output
cargo tarpaulin --out Html

# With specific options
cargo tarpaulin \
    --workspace \
    --timeout 120 \
    --out Xml Html \
    --output-dir coverage/ \
    --exclude-files "*/tests/*" "*/benches/*" \
    --ignore-panics

# Skip certain crates
cargo tarpaulin --workspace --exclude diag_tool  # exclude the binary crate

tarpaulin vs llvm-cov comparison:

Featurecargo-llvm-covcargo-tarpaulin
AccuracySource-based (most accurate)Ptrace-based (occasional overcounting)
PlatformAny (llvm-based)Linux only
Branch coverageYesLimited
Doc testsYesNo
SetupNeeds llvm-tools-previewSelf-contained
SpeedFaster (compile-time instrumentation)Slower (ptrace overhead)
StabilityVery stableOccasional false positives

Recommendation: Use cargo-llvm-cov for accuracy. Use cargo-tarpaulin when you need a quick check without installing LLVM tools.

grcov โ€” Mozilla's Coverage Tool

grcov is Mozilla's coverage aggregator. It consumes raw LLVM profiling data and produces reports in multiple formats:

# Install
cargo install grcov

# Step 1: Build with coverage instrumentation
export RUSTFLAGS="-Cinstrument-coverage"
export LLVM_PROFILE_FILE="target/coverage/%p-%m.profraw"
cargo build --tests

# Step 2: Run tests (generates .profraw files)
cargo test

# Step 3: Aggregate with grcov
grcov target/coverage/ \
    --binary-path target/debug/ \
    --source-dir . \
    --output-types html,lcov \
    --output-path target/coverage/report \
    --branch \
    --ignore-not-existing \
    --ignore "*/tests/*" \
    --ignore "*/.cargo/*"

# Step 4: View report
open target/coverage/report/html/index.html

When to use grcov: It's most useful when you need to merge coverage from multiple test runs (e.g., unit tests + integration tests + fuzz tests) into a single report.

Coverage in CI: Codecov and Coveralls

Upload coverage data to a tracking service for historical trends and PR annotations:

# .github/workflows/coverage.yml
name: Code Coverage

on: [push, pull_request]

jobs:
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: llvm-tools-preview

      - name: Install cargo-llvm-cov
        uses: taiki-e/install-action@cargo-llvm-cov

      - name: Generate coverage
        run: cargo llvm-cov --workspace --lcov --output-path lcov.info

      - name: Upload to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: lcov.info
          token: ${{ secrets.CODECOV_TOKEN }}
          fail_ci_if_error: true

      # Optional: enforce minimum coverage
      - name: Check coverage threshold
        run: |
          cargo llvm-cov --workspace --fail-under-lines 80
          # Fails the build if line coverage drops below 80%

Coverage gates โ€” enforce minimums per crate by reading the JSON output:

# Get per-crate coverage as JSON
cargo llvm-cov --workspace --json | jq '.data[0].totals.lines.percent'

# Fail if below threshold
cargo llvm-cov --workspace --fail-under-lines 80
cargo llvm-cov --workspace --fail-under-functions 70
cargo llvm-cov --workspace --fail-under-regions 60

Coverage-Guided Testing Strategy

Coverage numbers alone are meaningless without a strategy. Here's how to use coverage data effectively:

Step 1: Triage by risk

High coverage, high risk     โ†’ โœ… Good โ€” maintain it
High coverage, low risk      โ†’ ๐Ÿ”„ Possibly over-tested โ€” skip if slow
Low coverage, high risk      โ†’ ๐Ÿ”ด Write tests NOW โ€” this is where bugs hide
Low coverage, low risk       โ†’ ๐ŸŸก Track but don't panic

Step 2: Focus on branch coverage, not line coverage

// 100% line coverage, 50% branch coverage โ€” still risky!
pub fn classify_temperature(temp_c: i32) -> ThermalState {
    if temp_c > 105 {       // โ† tested with temp=110 โ†’ Critical
        ThermalState::Critical
    } else if temp_c > 85 { // โ† tested with temp=90 โ†’ Warning
        ThermalState::Warning
    } else if temp_c < -10 { // โ† NEVER TESTED โ†’ sensor error case missed
        ThermalState::SensorError
    } else {
        ThermalState::Normal  // โ† tested with temp=25 โ†’ Normal
    }
}

Step 3: Exclude noise

# Exclude test code from coverage (it's always "covered")
cargo llvm-cov --workspace --ignore-filename-regex 'tests?\.rs$|benches/'

# Exclude generated code
cargo llvm-cov --workspace --ignore-filename-regex 'target/'

In code, mark untestable sections:

// Coverage tools recognize this pattern
#[cfg(not(tarpaulin_include))]  // tarpaulin
fn unreachable_hardware_path() {
    // This path requires actual GPU hardware to trigger
}

// For llvm-cov, use a more targeted approach:
// Simply accept that some paths need integration/hardware tests,
// not unit tests. Track them in a coverage exceptions list.

Complementary Testing Tools

proptest โ€” Property-Based Testing finds edge cases that hand-written tests miss:

[dev-dependencies]
proptest = "1"
use proptest::prelude::*;

proptest! {
    #[test]
    fn parse_never_panics(input in "\\PC*") {
        // proptest generates thousands of random strings
        // If parse_gpu_csv panics on any input, the test fails
        // and proptest minimizes the failing case for you.
        let _ = parse_gpu_csv(&input);
    }

    #[test]
    fn temperature_roundtrip(raw in 0u16..4096) {
        let temp = Temperature::from_raw(raw);
        let md = temp.millidegrees_c();
        // Property: millidegrees should always be derivable from raw
        assert_eq!(md, (raw as i32) * 625 / 10);
    }
}

insta โ€” Snapshot Testing for large structured outputs (JSON, text reports):

[dev-dependencies]
insta = { version = "1", features = ["json"] }
#[test]
fn test_der_report_format() {
    let report = generate_der_report(&test_results);
    // First run: creates a snapshot file. Subsequent runs: compares against it.
    // Run `cargo insta review` to accept changes interactively.
    insta::assert_json_snapshot!(report);
}

When to add proptest/insta: If your unit tests are all "happy path" examples, proptest will find the edge cases you missed. If you're testing large output formats (JSON reports, DER records), insta snapshots are faster to write and maintain than hand-written assertions.

Application: 1,000+ Tests Coverage Map

The project has 1,000+ tests but no coverage tracking. Adding it reveals the testing investment distribution. Uncovered paths are prime candidates for Miri and sanitizer verification:

Recommended coverage configuration:

# Quick workspace coverage (proposed CI command)
cargo llvm-cov --workspace \
    --ignore-filename-regex 'tests?\.rs$' \
    --fail-under-lines 75 \
    --html

# Per-crate coverage for targeted improvement
for crate in accel_diag event_log topology_lib network_diag compute_diag fan_diag; do
    echo "=== $crate ==="
    cargo llvm-cov --package "$crate" --json 2>/dev/null | \
        jq -r '.data[0].totals | "Lines: \(.lines.percent | round)%  Branches: \(.branches.percent | round)%"'
done

Expected high-coverage crates (based on test density):

  • topology_lib โ€” 922-line golden-file test suite
  • event_log โ€” registry with create_test_record() helpers
  • cable_diag โ€” make_test_event() / make_test_context() patterns

Expected coverage gaps (based on code inspection):

  • Error handling arms in IPMI communication paths
  • GPU hardware-specific branches (require actual GPU)
  • dmesg parsing edge cases (platform-dependent output)

The 80/20 rule of coverage: Getting from 0% to 80% coverage is straightforward. Getting from 80% to 95% requires increasingly contrived test scenarios. Getting from 95% to 100% requires #[cfg(not(...))] exclusions and is rarely worth the effort. Target 80% line coverage and 70% branch coverage as a practical floor.

Troubleshooting Coverage

SymptomCauseFix
llvm-cov shows 0% for all filesInstrumentation not appliedEnsure you run cargo llvm-cov, not cargo test + llvm-cov separately
Coverage counts unreachable!() as uncoveredThose branches exist in compiled codeUse #[cfg(not(tarpaulin_include))] or add to exclusion regex
Test binary crashes under coverageInstrumentation + sanitizer conflictDon't combine cargo llvm-cov with -Zsanitizer=address; run them separately
Coverage differs between llvm-cov and tarpaulinDifferent instrumentation techniquesUse llvm-cov as source of truth (compiler-native); file issues for large discrepancies
error: profraw file is malformedTest binary crashed mid-executionFix the test failure first; profraw files are corrupt when the process exits abnormally
Branch coverage seems impossibly lowOptimizer creates branches for match arms, unwrap, etc.Focus on line coverage for practical thresholds; branch coverage is inherently lower

Try It Yourself

  1. Measure coverage on your project: Run cargo llvm-cov --workspace --html and open the report. Find the three files with the lowest coverage. Are they untested, or inherently hard to test (hardware-dependent code)?

  2. Set a coverage gate: Add cargo llvm-cov --workspace --fail-under-lines 60 to your CI. Intentionally comment out a test and verify CI fails. Then raise the threshold to your project's actual coverage level minus 2%.

  3. Branch vs. line coverage: Write a function with a 3-arm match and test only 2 arms. Compare line coverage (may show 66%) vs. branch coverage (may show 50%). Which metric is more useful for your project?

Coverage Tool Selection

flowchart TD
    START["Need code coverage?"] --> ACCURACY{"Priority?"}
    
    ACCURACY -->|"Most accurate"| LLVM["cargo-llvm-cov\nSource-based, compiler-native"]
    ACCURACY -->|"Quick check"| TARP["cargo-tarpaulin\nLinux only, fast"]
    ACCURACY -->|"Multi-run aggregate"| GRCOV["grcov\nMozilla, combines profiles"]
    
    LLVM --> CI_GATE["CI coverage gate\n--fail-under-lines 80"]
    TARP --> CI_GATE
    
    CI_GATE --> UPLOAD{"Upload to?"}
    UPLOAD -->|"Codecov"| CODECOV["codecov/codecov-action"]
    UPLOAD -->|"Coveralls"| COVERALLS["coverallsapp/github-action"]
    
    style LLVM fill:#91e5a3,color:#000
    style TARP fill:#e3f2fd,color:#000
    style GRCOV fill:#e3f2fd,color:#000
    style CI_GATE fill:#ffd43b,color:#000

๐Ÿ‹๏ธ Exercises

๐ŸŸข Exercise 1: First Coverage Report

Install cargo-llvm-cov, run it on any Rust project, and open the HTML report. Find the three files with the lowest line coverage.

<details> <summary>Solution</summary>
cargo install cargo-llvm-cov
cargo llvm-cov --workspace --html --open
# The report sorts files by coverage โ€” lowest at the bottom
# Look for files under 50% โ€” those are your blind spots
</details>

๐ŸŸก Exercise 2: CI Coverage Gate

Add a coverage gate to a GitHub Actions workflow that fails if line coverage drops below 60%. Verify it works by commenting out a test.

<details> <summary>Solution</summary>
# .github/workflows/coverage.yml
name: Coverage
on: [push, pull_request]
jobs:
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: llvm-tools-preview
      - run: cargo install cargo-llvm-cov
      - run: cargo llvm-cov --workspace --fail-under-lines 60

Comment out a test, push, and watch the workflow fail.

</details>

Key Takeaways

  • cargo-llvm-cov is the most accurate coverage tool for Rust โ€” it uses the compiler's own instrumentation
  • Coverage doesn't prove correctness, but zero coverage proves zero testing โ€” use it to find blind spots
  • Set a coverage gate in CI (e.g., --fail-under-lines 80) to prevent regressions
  • Don't chase 100% coverage โ€” focus on high-risk code paths (error handling, unsafe, parsing)
  • Never combine coverage instrumentation with sanitizers in the same run