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-unknownor a WASI target. - [ ]
cargo-llvm-cov(cargo install cargo-llvm-cov) and thellvm-tools-previewrustup 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.
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.
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_builtinsnot found for the wasm target. The standard toolchain has no profiler runtime for Wasm. Use native coverage, or theminicovpath with-Zno-profiler-runtime.- Coverage report shows zero for every file. The binary passed to
llvm-covis not the one that ran, or it was stripped. Point it at the exact test binary, unoptimized. lib.rsdrags 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.
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.
Related
- Unit testing Rust Wasm with wasm-bindgen-test — the tests whose coverage you measure.
- Differential testing Wasm against native builds — confirming native coverage transfers.
- Testing Wasm modules with Vitest — covering the JavaScript side.
- Setting up CI/CD for Rust Wasm projects — where the coverage job runs.
← Back to Testing & Verifying Wasm Builds