
On this page
Robust Rust: How Code Coverage Powers Rust Software Quality
How code coverage techniques, LLVM instrumentation, and tooling (tarpaulin, grcov, cargo-llvm-cov) ensure software reliability in Rust.
Co-authored with Amit Dharmapurikar and Atri Sarkar.
Introduction
Code coverage is a crucial metric which can provide good insight about software quality. Code coverage is typically expressed in percentage and provides information about how much of your software and which parts of your code have been tested by a set of unit tests or a test suite. Imagine it as a text highlighting tool which shows which parts of code are tested and which parts of the code can be considered for further testing in order to improve the quality of your code.
Higher code coverage increases confidence in your code’s functionality and reduces the risk of bugs. Most code coverage tools can be integrated into a CI pipeline so that code coverage is continuously monitored as your code grows. In several projects, code coverage requirements are included in merge policies to ensure software quality. As code coverage tools also report the areas of code which are not tested by the test suite, it helps to identify focus areas for testing. In certain regulated domains like automotive, aerospace, and healthcare where safety is crucial, code coverage requirements are part of the qualification and certification process.
There are various methods available for estimating code coverage by a test suite:
- Statement coverage: Although a more popular term is line coverage, in many languages there could be multiple statements on a single line, so purists prefer statement coverage. If a statement is executed at least once when running the test suite, it is considered covered.
- Region coverage: The percentage of code regions that have been executed. A code region may span multiple lines consisting of multiple statements, such as a function body.
- Branch coverage: A metric used to assess how many branches or decision points in a program’s control flow have been executed by test cases (e.g.
ifstatements ormatcharms). - MC/DC coverage: Modified Condition/Decision Coverage is a rigorous testing technique used in safety-critical systems. It ensures every decision has been tested under all possible outcomes, and each condition within a decision can independently affect the outcome while requiring fewer test cases than full condition coverage.
In this blog, we will discuss how to generate code coverage information for Rust applications. We will also discuss the current state and features expected in future versions of the Rust compiler related to code coverage. Rust uses LLVM as its compilation backend and leverages instrumentation coverage features provided by LLVM.
Enabling code coverage in Rust
Code coverage data in Rust can be collected with two methods:
-
Using
-Z profileflag: Uses GCC-compatiblegcov-based coverage based onDebugInfo. This provides a clear roadmap of code execution. -
Using
-C instrument-coverageflag: Enables LLVM’s native coverage instrumentation, known for high efficiency and precision.
In this blog, we focus on -C instrument-coverage in detail as it is widely used for modern source-based code coverage.
The following simple example shows how this works:
pub fn is_even(n: u32) -> bool {
if n % 2 == 0 {
true
} else {
false
}
}
and its corresponding test:
#[test]
pub fn test_even() {
assert_eq!(is_even(2), true);
}
To enable code coverage, we pass the -C instrument-coverage flag while compiling with the Rust compiler.
When using Cargo, we enable this flag using the RUSTFLAGS environment variable:
RUSTFLAGS="-C instrument-coverage" cargo test
This produces an “instrumented” binary with extra code injected to count lines, regions, and branches.
Effectively, the compiler injects counter increments like shown below:
pub fn is_even(n: u32) -> bool {
is_even_counter += 1;
if n % 2 == 0 {
is_even_true_branch_counter += 1;
true
} else {
is_even_false_branch_counter += 1;
false
}
}
Whenever a specific function, region, or branch is entered, an associated counter is incremented.
Apart from this, a runtime is also injected into the binary which initializes and manages these counters throughout program execution (the LLVM profiler runtime):
// Implicit main of test binary
pub fn main() {
__initialize_counters();
test_even();
// Other tests...
__write_counter_data_to_file();
}
When the program exits, raw counter data is written to a .profraw file which is then used to create coverage reports.
Note: This is an intentional simplification. The compiler doesn’t actually inject counters at the source level. During compilation, source code is lowered through intermediate representations (MIR), where counter injection actually takes place.
Code coverage tools in Rust
Popular coverage tools in the Rust ecosystem:
- Tarpaulin: A tool providing source code line coverage tailored specifically for Cargo projects.
- grcov: An aggregation tool that collects coverage data from test suites using
llvm-covto create HTML/lcov reports. - cargo-llvm-cov: A modern Cargo subcommand that wraps LLVM source-based code coverage.
Using cargo-tarpaulin
# Install cargo-tarpaulin
cargo install cargo-tarpaulin
# Run cargo-tarpaulin to collect coverage data
cargo tarpaulin
Using grcov
# Install grcov
cargo install grcov
# Run your tests with coverage instrumentation
RUSTFLAGS="-C instrument-coverage" cargo test
# Generate HTML coverage report
grcov . -s . --binary-path ./target/debug/ -t html --ignore tests/ -o ./target/debug/coverage/
# Open the report in your browser
open target/debug/coverage/index.html
Using cargo-llvm-cov
# Install cargo-llvm-cov
cargo install cargo-llvm-cov
# Run tests with coverage and print report to console
cargo llvm-cov
Conclusion
Code coverage provides highly valuable insights for assessing and improving the quality of software. Rust provides strong support for line and region coverage with ongoing advancements in LLVM for branch and MC/DC coverage.