How to Measure Code Coverage in Rust

Install cargo-tarpaulin and run cargo tarpaulin to generate an HTML report of your Rust code coverage.

The green checkmark lie

You merge a pull request. The CI pipeline finishes. Every test passes. The dashboard shows a green checkmark. You deploy to production. Three hours later, a user reports a crash. The crash happens in a function your tests "covered." The coverage report showed that line executed. So how did it break?

The test called the function, but it never hit the branch that handled the error case. The coverage tool marked the line as visited because the function body ran, but the specific path that caused the crash remained untouched. Coverage numbers can be deceptive. They tell you what code executed, not what behavior you verified. Measuring coverage in Rust isn't about chasing a perfect score. It's about finding the blind spots in your test suite so you can fix them before they hit users.

What coverage actually measures

Code coverage is a map of execution. You run your test suite, and the tool marks every line of code that the CPU touched. If a line is unmarked, your tests never reached it. Rust doesn't bake this into the compiler by default. You need a tool to instrument your code and collect the data.

The ecosystem has settled on cargo-llvm-cov. It uses the same instrumentation backend as Clang, giving you fast, accurate results without the overhead of older tools. Think of coverage like motion sensors in a house. The sensors don't tell you if the intruder stole the TV. They just tell you which rooms were entered. Coverage is the motion sensor. It shows you where your tests walked, and where they didn't.

Coverage reports usually break down into lines, branches, and functions. Line coverage tracks individual source lines. Branch coverage tracks the outcomes of conditionals. Function coverage tracks whether a function body was entered. Branch coverage is the most revealing. A line with an if statement can be covered while one branch remains dead. Line coverage will show 100%. Branch coverage will expose the gap.

Coverage maps the territory. It doesn't guarantee the destination.

Getting started with cargo-llvm-cov

The standard tool is cargo-llvm-cov. It works as a cargo subcommand, so the workflow feels native. Install it once, and it stays on your system.

cargo install cargo-llvm-cov

Create a simple project to see how it works. Write a function with a conditional branch. Add a test that only hits one path.

/// Greets a user by name.
/// Returns a greeting string.
pub fn greet(name: &str) -> String {
    // Check if the name is empty.
    // This creates two branches: the empty case and the normal case.
    if name.is_empty() {
        "Hello, stranger!".to_string()
    } else {
        format!("Hello, {}!", name)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_greet_normal() {
        // This test calls greet with a non-empty string.
        // It exercises the else branch.
        assert_eq!(greet("Alice"), "Hello, Alice!");
    }
}

Run the coverage tool. The command cargo llvm-cov compiles your code with instrumentation, runs the tests, and prints the report.

cargo llvm-cov

The output looks like this:

Line coverage: 75.00% (6/8 lines)

The report shows 75% coverage. The if branch that returns "Hello, stranger!" was never executed. The tool highlights the missing line. Add a test for the empty string, run the command again, and the coverage jumps to 100%.

    #[test]
    fn test_greet_empty() {
        // This test hits the if branch.
        // Now both paths are covered.
        assert_eq!(greet(""), "Hello, stranger!");
    }

Run the tool again. The report shows 100% line coverage. The gap is closed.

How the instrumentation works

cargo-llvm-cov doesn't modify your source code. It tells rustc to inject extra instructions into the binary. The compiler adds counters to your code. Every time a line executes, the runtime updates the corresponding counter. The compiler also generates a .profraw file that records the counter values.

When the tests finish, the tool reads the .profraw file. It correlates the counters with the source map to determine which lines ran. This happens at compile time and runtime. No macros. No source changes. The instrumentation is transparent.

The overhead is small. Compilation takes slightly longer because the compiler has to generate the instrumentation. Test execution is slower because the runtime has to update counters. The slowdown is usually acceptable for local runs. In massive CI suites, you might notice the extra time. You pay a small speed tax to get the map.

The tool also supports branch coverage. The compiler instruments conditional jumps. The report tracks which outcomes occurred. This is why cargo-llvm-cov is more accurate than older tools that relied on source-level parsing. The data comes directly from the LLVM backend.

Real-world usage: Workspaces and reports

Real projects have multiple crates. You need coverage across the whole workspace. Use the --workspace flag to include all members.

cargo llvm-cov --workspace

For code reviews, HTML reports are easier to share. Generate an HTML report with --html. The tool writes the files to a directory.

cargo llvm-cov --workspace --html --output-dir coverage-report

Open coverage-report/index.html in your browser. The report shows a tree of your crates. Click through to see files. Lines are colored green or red based on coverage. You can see exactly which lines are missing.

Developers rarely generate HTML reports locally. The convention is to use --show-missing during development. This flag prints the uncovered lines directly in the terminal. No clicking. No browser. You get immediate feedback.

cargo llvm-cov --show-missing

The output lists files and line numbers for every uncovered line. This is the daily driver. Run this before every commit to check your work. Many developers alias this command to save keystrokes.

# Add to your shell config
alias cov='cargo llvm-cov --show-missing'

Now you can type cov to check coverage instantly. The community treats this as a standard part of the test loop. Tests pass, coverage looks good, then commit.

Handling noise and generated code

Coverage tools can get noisy. Generated code, build scripts, and test helpers can clutter the report. cargo-llvm-cov lets you exclude files using --ignore-filename-regex.

cargo llvm-cov --ignore-filename-regex "build.rs|target/"

This command ignores build.rs files and anything in the target directory. You can chain patterns. Exclude serde generated code, mock modules, or third-party dependencies.

cargo llvm-cov \
    --ignore-filename-regex "build.rs" \
    --ignore-filename-regex "tests/" \
    --ignore-filename-regex "mock_"

The regex matches against the file path. Be careful with broad patterns. You might accidentally hide real gaps. Start with specific exclusions. Add more only when the noise becomes unmanageable.

Convention aside: Most projects exclude tests/ directories from coverage. Test code doesn't need to be covered by tests. Excluding it keeps the report focused on production code. However, some teams keep test coverage to ensure their test helpers are used. Pick the policy that fits your team.

Branch coverage versus line coverage

Line coverage is the baseline. Branch coverage is the truth. A line with a conditional can be covered while one branch remains dead. Line coverage will show 100%. Branch coverage will expose the gap.

cargo-llvm-cov reports branch coverage by default. Watch the BR column in the output. If BR is lower than LN, you have untested conditions.

File                          Lines    Branches  Functions
lib.rs                         100%     80%       100%

This report shows 100% line coverage but only 80% branch coverage. Some if or match arms are missing tests. Dig into the report to find the gaps. Fix the tests to hit the missing branches.

Branch coverage catches more bugs. It forces you to test error paths, edge cases, and alternative logic. Line coverage can hide these gaps. Always check branch coverage. If your CI only checks line coverage, you're missing half the picture.

Pitfalls and false confidence

Coverage tools reveal gaps, but they can also create false confidence. High coverage doesn't mean good tests. You can have 100% coverage with zero assertions. The test runs the code, does nothing, and passes. Coverage measures execution, not correctness.

Mocking creates another pitfall. If you mock a dependency, the real code isn't covered. That's expected. Don't chase coverage into mocks. The mock is a substitute. The real code needs its own tests. Coverage tools usually exclude mocked code, but sometimes the boundaries blur. Review your mocks to ensure the real implementation is tested separately.

Unreachable code confuses coverage tools. match arms with unreachable!() might be flagged as uncovered. The compiler optimizes unreachable code, so the instrumentation might not fire. This is a false positive. Ignore coverage warnings on code the compiler knows is unreachable.

Test-only code can skew results. #[cfg(test)] modules are usually excluded. If you have shared test utilities, they might be excluded too. Ensure your exclusions don't hide gaps in your test infrastructure.

Coverage is a flashlight, not a judge. Use it to find dark corners, not to grade your tests.

When to use coverage tools

Use cargo-llvm-cov when you want fast, accurate coverage with minimal setup. It integrates with the standard build system and provides detailed reports.

Use cargo llvm-cov --show-missing when you are iterating locally and need immediate feedback on uncovered lines. This flag is the fastest way to check your work during development.

Use cargo llvm-cov --html when you need a shareable report for code review or CI dashboards. HTML reports make it easy for the team to see coverage gaps.

Use cargo llvm-cov --fail-under-lines when you want to enforce coverage thresholds in CI. This flag fails the build if coverage drops below a percentage. It prevents regressions.

Reach for manual inspection when coverage is low but the code is trivially simple or intentionally unreachable. Not every line needs a test. Some code is self-evident. Trust your judgment when the metric doesn't match the risk.

Pick the tool that fits the workflow. cargo-llvm-cov is the standard for a reason.

Where to go next