How to Use cargo-tarpaulin for Code Coverage

Run cargo tarpaulin to measure test coverage and generate an HTML report for your Rust project.

When tests pass but code hides

You just finished a feature. The tests pass. Green bar. You push to CI. But somewhere in that module, there's a branch that handles a rare edge case, and your tests never touch it. You don't know that yet. You ship it. The edge case hits in production.

Now you need a way to see exactly which lines of code your tests executed and which ones are hiding in the dark. That's what code coverage does. It runs your tests and highlights the lines that executed. Lines that never ran stay unhighlighted. You get a map of your test suite's reach.

cargo-tarpaulin is the tool that generates this map. It instruments your code, runs the tests, and produces a report showing exactly what happened.

Coverage is a map, not a guarantee

Code coverage measures execution, not correctness. It tells you which lines ran. It does not tell you if the logic is right. You can have 100% coverage and still have a bug if your assertion is wrong.

Think of your codebase as a maze. Your tests are runners. Coverage is the trail of breadcrumbs they leave behind. If a corridor has no breadcrumbs, no runner ever went there. You might have a trapdoor in that corridor, or you might just have a dead end. Coverage shows you the empty corridors so you can decide whether to send a runner in there.

Coverage also distinguishes between line coverage and branch coverage. Line coverage checks if a line executed. Branch coverage checks if every possible path through a line executed. A line with an if statement might run, but if your tests only hit the true branch, branch coverage will flag the missing false path. Tarpaulin reports both.

Minimal example

Start with a simple crate. Install the tool and run it.

cargo install cargo-tarpaulin

Create a library with a function and a test.

// src/lib.rs

/// Returns true if n is even.
pub fn is_even(n: i32) -> bool {
    if n % 2 == 0 {
        true
    } else {
        false
    }
}

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

    #[test]
    fn test_even() {
        // Only tests the true branch.
        assert!(is_even(4));
    }
}

Run tarpaulin.

cargo tarpaulin --out Html

Tarpaulin compiles your code with instrumentation, runs the test, and generates target/tarpaulin-report.html. Open that file in a browser. You'll see is_even highlighted. The true branch is green. The false branch is red. The report tells you exactly what you missed.

Add a test for the odd case. Run tarpaulin again. Both branches turn green.

    #[test]
    fn test_odd() {
        assert!(!is_even(3));
    }

Convention aside: cargo tarpaulin runs your tests for you. You don't need to run cargo test separately. Tarpaulin invokes the test binary with instrumentation. Running both is redundant and slows down your workflow.

How tarpaulin instruments your code

Tarpaulin doesn't just watch your tests run. It changes how your code is compiled. When you invoke cargo tarpaulin, it passes special flags to the compiler. The compiler injects tiny counters at the start of every executable line.

When the test binary runs, those counters increment. Tarpaulin reads the counters after the run and maps them back to your source files. It generates a report showing which lines hit zero.

The tool supports different engines. The default engine uses source-level mapping. You can switch to LLVM instrumentation with --engine llvm. LLVM instrumentation is often more accurate for complex macros and generated code because it works at the intermediate representation level rather than trying to map back from object files.

cargo tarpaulin --engine llvm --out Html

Use the LLVM engine when you see gaps in coverage that don't make sense, or when macros are confusing the mapper. The LLVM engine requires a compatible LLVM version, but it handles edge cases better.

Real-world flags and CI

Real projects have structure. You have main.rs files that are hard to test. You have generated code. You have dependencies you don't want to count. Tarpaulin provides flags to handle this.

Exclude files that shouldn't count toward your score.

cargo tarpaulin --exclude-files "src/main.rs" --exclude-files "src/generated/**" --out Html

Ignore test code itself. If you don't exclude tests, they inflate your coverage score because test code usually runs 100%.

cargo tarpaulin --ignore-tests --out Html

For CI pipelines, you need a machine-readable format. HTML is for humans. LCOV is for tools. Generate LCOV and upload it to a coverage service.

cargo tarpaulin --out Lcov --output-file lcov.info

Add this to your GitHub Actions workflow.

- name: Install tarpaulin
  run: cargo install cargo-tarpaulin

- name: Run coverage
  run: cargo tarpaulin --out Lcov --output-file lcov.info --ignore-tests

- name: Upload coverage
  uses: codecov/codecov-action@v3
  with:
    files: lcov.info

Convention aside: Add tarpaulin-report.html and lcov.info to your .gitignore. Coverage files are artifacts. They change every run. They don't belong in version control. Keep your repo clean.

Pitfalls and the coverage trap

Coverage metrics can mislead. Watch for these traps.

The assertion trap. Coverage measures execution, not logic. If you write assert_eq!(calculate_tax(100), 0) and calculate_tax returns 10, the test fails. Coverage is 100%. The line ran. The assertion is wrong. Coverage doesn't care. Treat coverage as a flashlight, not a seal of approval.

The macro trap. Macros expand into code. Tarpaulin maps coverage to the expanded code or the macro call site depending on the engine. Sometimes you'll see a macro call highlighted as covered, but the internal branches aren't visible. Switch to --engine llvm if macro coverage looks incomplete.

The FFI trap. Code that calls C or other languages via FFI might not instrument correctly. The instrumentation happens in Rust. If the execution jumps into a C library and back, the mapping can break. Expect gaps around extern blocks.

The test inflation trap. If you count test code, your score goes up artificially. Test code is usually simple and runs fully. Use --ignore-tests to get a score that reflects your production code.

Compiler errors can appear if instrumentation fails. You might see a linker error or a tarpaulin warning about missing debug info. Ensure you aren't stripping debug symbols. Tarpaulin needs debug info to map counters back to source lines. If you build with --release and strip symbols, coverage breaks. Tarpaulin builds with debug info by default. Don't override this unless you know what you're doing.

Don't chase 100% coverage. Chase understanding. Use coverage to find the dark corners. Fix the gaps that matter. Ignore the gaps that are impossible or irrelevant.

Decision: tarpaulin vs llvm-cov vs grcov

Use cargo-tarpaulin when you want a stable, easy-to-install tool that works on any platform without nightly Rust. Tarpaulin runs on stable. It installs via cargo install. It handles most projects well. It's the safe default for teams that can't use nightly.

Use cargo-llvm-cov when you need precise branch coverage, better macro handling, and you are okay with requiring nightly Rust. LLVM-cov uses the LLVM coverage API directly. It's more accurate. It handles complex code better. It requires nightly. If your project is already on nightly, llvm-cov is usually the better choice.

Use grcov when you are working with a complex monorepo or need to merge coverage data from multiple test runs manually. Grcov is a coverage report generator. It doesn't run tests. It takes raw coverage data and merges it. You pair it with a tool that produces raw data. It's powerful but requires more setup.

Pick the tool that fits your CI constraints. If you can use nightly, llvm-cov is usually more accurate. If you need stable, tarpaulin gets the job done.

Where to go next