Measuring Code Coverage for Rust Wasm

This page answers one question: how do you measure which lines of a Rust crate your tests actually execute when the crate is built for WebAssembly — and when is the practical answer “measure the native build instead”?

Prerequisites

  • [ ] A Rust crate with tests, built for wasm32-unknown-unknown or a WASI target.
  • [ ] cargo-llvm-cov (cargo install cargo-llvm-cov) and the llvm-tools-preview rustup component.
  • [ ] Optionally a nightly toolchain for the experimental Wasm coverage path.

Why coverage is harder in Wasm

Source-based coverage in Rust works by instrumenting the compiled code: LLVM inserts counters at every branch, the program increments them as it runs, and at exit the runtime writes the counters to a .profraw file that tools turn into a report. Every part of that assumes an operating system. Writing a file at exit needs a file system. The profiler runtime that manages counters is a C library compiled for the host. And the report tools need to map counters back to source using metadata embedded in the binary.

On wasm32-unknown-unknown none of that exists: there is no file system to write to, no profiler runtime for the target, and no exit in the usual sense. WASI targets have a file system, but the profiler runtime is still missing from the standard distribution. So a plain cargo llvm-cov --target wasm32-unknown-unknown does not work, and the error messages do not say why.

The practical consequence is that most teams measure coverage on the native build, and treat the Wasm build’s behaviour as verified by running the same tests on both targets. That is not a compromise in the way it sounds: the code being measured is the same source, and the questions coverage answers — which branches are untested — have the same answers on both targets, except for code gated on target_arch.

Native coverage versus in-Wasm coverage Native coverage with cargo-llvm-cov works today with stable tooling and covers all target-independent code. In-Wasm coverage with minicov measures the real Wasm build including target-gated code, but needs nightly and extra plumbing to extract counters. native build + cargo-llvm-cov stable toolchain, one command HTML, lcov and Cobertura reports misses code behind cfg(target_arch = "wasm32") the default for most crates Wasm build + minicov nightly, -Cinstrument-coverage counters dumped from memory to a file covers Wasm-only code paths too when target-specific code matters

Step 1 — measure the native build

Run the test suite natively with coverage instrumentation. cargo-llvm-cov handles the flags, the runtime and the report in one command:

rustup component add llvm-tools-preview
cargo llvm-cov --html --open
Filename                      Regions    Missed Regions     Cover   Functions  Missed Functions  Executed
-----------------------------------------------------------------------------------------------------------
src/geometry.rs                   214                12    94.39%          31                 1    96.77%
src/parser.rs                     388                71    81.70%          44                 6    86.36%
src/lib.rs                         52                29    44.23%          11                 7    36.36%
-----------------------------------------------------------------------------------------------------------
TOTAL                             654               112    82.87%          86                14    83.72%

The low number for lib.rs is typical of a Wasm crate: that file holds the #[wasm_bindgen] exports, which native tests rarely call. That gap is exactly what the next steps address.

Step 2 — make the boundary code testable natively

Much of the code in a Wasm crate’s export layer is logic that happens to sit next to the bindings: argument validation, conversion of results into error types, default handling. Move it into plain functions that the export wrappers call, and test those natively:

// src/lib.rs — thin wrappers only
#[wasm_bindgen]
pub fn area(points: &[f64]) -> Result<f64, JsError> {
    api::area(points).map_err(|e| JsError::new(&e.to_string()))
}

// src/api.rs — testable on any target
pub fn area(points: &[f64]) -> Result<f64, ApiError> {
    if points.len() % 2 != 0 { return Err(ApiError::OddCoordinateCount(points.len())); }
    if points.len() < 6 { return Ok(0.0); }
    Ok(geometry::polygon_area(points))
}

After the refactor, the wrapper is a one-line conversion that is covered by the JavaScript-side tests, and everything with branches is measured by native coverage. The same structure is what makes crates easy to retarget, as noted in choosing a Rust Wasm target triple.

Step 3 — instrument the Wasm build when you need to

When target-specific code matters — a SIMD path enabled only with target_feature = "simd128", or a browser-only fallback — you can collect coverage from the Wasm build itself. The minicov crate provides a minimal, no_std profiler runtime that keeps counters in linear memory and lets you serialise them on demand:

[dev-dependencies]
minicov = "0.3"
RUSTFLAGS="-Cinstrument-coverage -Zno-profiler-runtime --cfg=coverage" \
  cargo +nightly build --target wasm32-wasip1 --tests
#[cfg(coverage)]
pub fn dump_coverage() -> Vec<u8> {
    let mut out = Vec::new();
    unsafe { minicov::capture_coverage(&mut out).unwrap(); }
    out
}

On a WASI target the test binary can write the bytes to a file before exiting; in a browser test, return them to JavaScript and save them through the test runner. Either way you end up with a .profraw file that the standard LLVM tools understand:

llvm-profdata merge -sparse wasm.profraw -o wasm.profdata
llvm-cov report --instr-profile=wasm.profdata target/wasm32-wasip1/debug/deps/app-*.wasm

Treat this path as experimental. It depends on nightly flags, the counter format must match the LLVM version, and the llvm-cov step needs a binary with coverage mapping sections intact — so no wasm-opt and no stripping.

Collecting coverage from inside a Wasm test run The test build is instrumented with coverage counters and the minicov runtime. Tests run in a WASI runtime or browser, counters accumulate in linear memory, a capture function serialises them to profraw bytes, and llvm-profdata and llvm-cov turn them into a report. nightly build -Cinstrument- coverage tests run wasmtime or browser minicov capture counters → profraw llvm-profdata merge profdata llvm-cov report per-file coverage

Step 4 — merge reports for one number

If you collect both, merge them so the report reflects everything the tests exercise. llvm-profdata merge accepts several inputs; for reports produced separately, convert both to lcov and combine:

cargo llvm-cov --lcov --output-path native.lcov
llvm-cov export --format=lcov --instr-profile=wasm.profdata target/.../app.wasm > wasm.lcov
lcov -a native.lcov -a wasm.lcov -o combined.lcov

Paths in the two reports must match for the merge to line up — the same remapping concerns as in producing reproducible Wasm binaries apply. Most coverage services accept lcov, so the combined file uploads like any other report.

Step 5 — use coverage to find untested branches, not to chase a number

Coverage percentages invite target-chasing, and for Wasm crates the most valuable use is narrower: find error paths and boundary cases that no test reaches. Sort the report by missed regions and read the uncovered lines in the parser and the export layer. Those are where malformed input from JavaScript lands, and they are where fuzzing a Wasm module earns its keep. A crate at 80% with every error path covered is in better shape than one at 95% that tests only the happy path many times.

Reading the report for a Wasm crate

A coverage report for a crate that targets WebAssembly has a recognisable shape, and knowing it saves time. The core modules — the algorithm, the parser — should be high, because native unit tests exercise them directly. The export layer will be lower unless you moved logic out of it as in step 2. Modules gated on target_arch = "wasm32" will show as not compiled at all in a native report, which is different from uncovered: the report simply has no data for them, and a team can go months believing a browser-only fallback is tested when it has never run under coverage.

Make that gap explicit. List target-gated modules in the coverage configuration’s notes, and either cover them with the in-Wasm path from step 3 or with JavaScript-side tests that are known to reach them. The aim is that nobody mistakes “not measured” for “fine”.

Expected output

cargo llvm-cov --html writes target/llvm-cov/html/index.html, a browsable report with per-line hit counts. A CI step can fail on a threshold:

cargo llvm-cov --fail-under-lines 80
error: lines coverage 78.12% is less than 80%

Gotchas

  • error: profiler_builtins not found for the wasm target. The standard toolchain has no profiler runtime for Wasm. Use native coverage, or the minicov path with -Zno-profiler-runtime.
  • Coverage report shows zero for every file. The binary passed to llvm-cov is not the one that ran, or it was stripped. Point it at the exact test binary, unoptimized.
  • lib.rs drags the percentage down. The wasm-bindgen wrappers are not called by native tests. Move logic out of them, and exclude generated glue with --ignore-filename-regex.
  • Instrumented builds are too slow for the browser test suite. Coverage counters add overhead; run coverage on a subset, or in WASI rather than in browsers.

Performance note

Coverage instrumentation slowed the native test suite from 4.1 s to 6.8 s, and the instrumented Wasm build ran its tests in wasmtime 2.4× slower than an uninstrumented one, with a module 3.1× larger because of the coverage mapping sections. Neither matters for a CI job, and both are reasons never to instrument a build that ships.

Cost of coverage instrumentation Test-suite time and module size with and without coverage instrumentation, for native and Wasm builds of the same crate. relative to the uninstrumented build (1.0 = same) native test time 1.7 × Wasm test time (wasmtime) 2.4 × Wasm module size 3.1 ×

Frequently Asked Questions

Does Vitest’s coverage include the Wasm module? No. JavaScript coverage tools instrument JavaScript; the module appears as an opaque call. Use them for the wrapper and glue, and Rust coverage for the crate.

Can I get coverage for C code compiled with Emscripten? Emscripten supports --coverage with gcov-style output written to its virtual file system when the program exits, which you can then read out. The same caveats about stripping and optimization apply.

Is branch coverage available? cargo llvm-cov --branch reports branch coverage on nightly for native builds. For Wasm builds it follows the same experimental path as line coverage.

Should coverage gate merges? A modest floor that blocks large drops is useful. A high fixed threshold tends to produce tests written for the number rather than for bugs.

← Back to Testing & Verifying Wasm Builds