---
url: >-
  https://thekevinscott.github.io/testing-conventions/reference/checks/unit-coverage.md
description: >-
  The unit-coverage check — the unit suite clears a 100% floor per language,
  whole-tree and over a pull request's changed lines; every key, default, and
  the line-scoped exemption.
---

# `unit-coverage`

The unit suite clears **a 100% floor** — whole-tree on every run, and over the changed lines on
every pull request. This page is the complete record of the check: why the floor is strict, what
each language measures, when the two jobs run, and every key that tunes it.

## Why this check exists

A test can exist without running the code — coverage is the
[unit ladder](/explanation/#the-unit-ladder-exist-→-run-→-verify)'s second rung: does the test
**run** it? And the floor is 100 rather than a comfortable 85 because a lower floor hides a
permanent, unexamined gap: at `fail_under = 85`, fifteen percent of the code is uncovered *and
nobody has said which fifteen or why* — the number stays green while the uncovered region drifts
to wherever tests are hardest to write. The standard inverts this: the floor is **100% of what
you didn't explicitly exempt**, so every uncovered line is either covered or named with a reason.

A covered line proves execution, nothing more — a test that calls the function and asserts
nothing covers every line it touches. [`mutation`](./mutation) is the rung above.

## What it enforces

The floor applies to the **unit suite only** — test files are excluded from the denominator, and
exempted files/lines are lifted from it. The exact keys and defaults are in the
[configuration reference](/reference/config#coverage).

* **Python** — the suite runs under `coverage.py` with branch coverage on; the combined line +
  branch total meets `fail_under`. A source with no branching constructs measures zero branches,
  which is vacuously full branch coverage — the total reduces to the line percent. `pytest`
  collects only the scanned path, with the standard suite tiers under `tests/` ignored, so the
  integration tier stays out of the unit lane the same way it stays out for TypeScript and Rust.
* **TypeScript** — the suite runs under `vitest` v8 coverage; **four independent metrics** (lines,
  branches, functions, statements) each meet their floor, because line coverage can read 100% while
  a branch lags.
* **Rust** — the suite runs under `cargo llvm-cov --lib --bins`, so the floor measures the same
  unit-only slice the other languages measure (the integration tier under `tests/` stays out of the
  number) across both the library and the binary targets — a binary's colocated tests are units too.
  The default floors **lines** only; three finer metrics are opt-in: `regions` is a Rust-only
  sub-line metric with a harsher bar and no cross-language analog, `functions` mirrors
  TypeScript's, and `branch` instruments on a nightly toolchain, which the crate pins in its own
  `rust-toolchain.toml` (with `llvm-tools-preview`) — the coverage run reads that pin, in CI and
  locally alike (the [config reference](/reference/config#coverage) has the exact keys). Keeping
  them opt-in keeps Rust's default floor line-shaped like Python's — the
  [parity](/explanation/#parity-over-cleverness) call, with the asymmetry named here.

## Where the tool runs

Each coverage run anchors where the consumer's own test run anchors, so the check measures the
suite under the same configuration your own runs use:

* **TypeScript** — vitest runs at the **scanned path** and resolves its configuration with its
  own upward search, so the package-root `vitest.config.*` that governs your own `vitest run`
  governs the check's run the same way: environment, setup files (resolved beside the config
  file), aliases, and plugins all apply. The check owns the measurement itself: its flags set the
  coverage scope (the scanned path's sources are the denominator, and the package's suite tiers
  under `tests/` stay out of the run), its floors come from your `testing-conventions.toml`
  config, and the run clears the config file's own global coverage thresholds — the check's
  floors decide, and your config file is left untouched (a `thresholds.autoUpdate` never
  rewrites it during a check run).
* **Python** — coverage.py runs at the **scanned path**, and pytest resolves its rootdir and
  configuration with its own upward search — so a package-root `[tool.pytest.ini_options]` /
  `pytest.ini` and the `conftest.py` files below it apply to the check's run exactly as to your
  own `pytest` run. The measurement itself is owned by the check — the scanned path's sources are
  the denominator (`--source`), branch coverage on, test files and exempted paths omitted, floors
  from your `testing-conventions.toml` config — so a `.coveragerc` / `[tool.coverage]` table is
  deliberately not consulted: the check holds the same keys it overrides in a vitest config, and
  the denominator is the scanned path's sources, addressing the same paths every other check
  uses. Scoping to the scanned path keeps an editable path dependency's source tree out of the
  number — a normal install lands in `site-packages` and is ignored either way, but an editable
  install resolves through a `.pth` to a tree that is not under `site-packages`, which
  coverage.py's default would otherwise pick up. (`# pragma: no cover` is coverage.py's built-in
  default and applies without configuration.) `pytest`'s collection is scoped the same way: a
  `tests/` directory directly beneath the scanned path is ignored, so a package whose scanned
  path *is* its package root never collects its own `tests/integration/` or `tests/e2e/` into the
  unit run.
* **Rust** — `cargo llvm-cov --lib --bins` anchors at the **crate root** by construction: cargo resolves
  the manifest upward from the scanned path, and `.cargo/config.toml` / `rust-toolchain.toml`
  discovery is cargo's and rustup's own, exactly as in your own `cargo test`.

### An item the test build never compiles

In Rust, an item a `#[cfg(not(test))]` gate keeps out of the test build is not a coverage subject,
whole-tree or changed-line. The unit tier runs `cargo llvm-cov --lib --bins`, which sets
`cfg(test)`, so no test can execute the item; the `--bins` half nevertheless links the library a
second time as a plain dependency of the binary target's test harness, where `cfg(test)` is unset,
and the gated item is compiled and instrumented there. Whether its counters reach the report depends
on how the linker partitioned that build — `CARGO_INCREMENTAL=0` alone flips it — so the check
drops the item's regions, lines, functions, and branches from the ratios instead.

The exclusion is item-level and covers only a gate a test build genuinely cannot satisfy:
`#[cfg(not(test))]` and `#[cfg(all(not(test), unix))]` qualify, `#[cfg(any(not(test), unix))]` still
compiles under `cargo test` and stays a subject. This is what makes the
[binary entry point `colocated-test` documents](/reference/checks/colocated-test#a-rust-binary-s-entry-point)
coverage-clean, the same way [`mutation`](./mutation) drops the mutants inside it.

### Which view the Rust line ratio reads

The same `--lib --bins` run compiles the library twice, so llvm-cov holds two instantiation
groups for every file in it. A `--json` export's `totals` sum each group's own line tally, which
counts a line both groups map twice — once covered and once not, when only one group ran it. The
floor reads the `DA:` records of an `lcov` export instead: llvm-cov writes those through the
merged line view that `llvm-cov show`, `--text` and `--show-missing-lines` all read, where a line
maps once however many groups carry it.

Both exports come from one run — the check re-reads the profile rather than running the suite
twice — so a failing line ratio always names lines those views can show. Regions, functions and
branches still come from the `--json` totals, where the same summing is what the gated-item
subtraction above is written against.

## The changed-line job

On pull requests, the same configured floor is also measured over only the lines the
`<base>...HEAD` diff added or modified. The thresholds stay the single source of truth; the
diff-scoped job only changes what they're measured over — a one-line change below the floor fails
however small the diff is, an added file's new lines are subjects, and a change touching no
measured line passes vacuously.

## When it runs

| Variant | Runs | As |
| --- | --- | --- |
| Whole-tree | always | its own job per language |
| Changed-line | pull requests only, over `<base>...HEAD` | its own job per language |

Both jobs install, provision, and build at the derived
[package root](/monorepo#source-vs-the-package-root): Python is provisioned by **uv**
(`uv sync` for an installable package, else a fresh `uv venv`, with the suite toolchain installed
into that same `.venv`); TypeScript installs with the package's own lockfile
(`pnpm install --frozen-lockfile` or `npm ci`) and runs `vitest` v8 coverage; Rust runs
`cargo llvm-cov --lib --bins` with no install step of its own. A package whose suite imports a compiled
module builds it first via [`build_command`](/reference/config#build-command). The
[`gates` input](/reference/workflow#inputs) names it `unit-coverage`; the changed-line variant
rides with it.

## Configuration

A `[<language>].coverage` table is a **partial override** — set only the fields you want to move,
the rest keep their default, and a typo'd key is rejected:

| Language | Keys | Default |
| --- | --- | --- |
| **Python** | `branch`, `fail_under` | `branch = true`, `fail_under = 100`. The suite always runs under coverage.py's branch instrumentation, so the whole-tree floor is its combined line + branch percent; a source with no branching constructs measures zero branches and is vacuously at full branch coverage. `branch` decides whether the diff-scoped and line-exempt ratios fold branch arcs in alongside lines. |
| **TypeScript** | `lines`, `branches`, `functions`, `statements` | All four at `100`, each enforced independently. |
| **Rust** | `lines`, `regions`, `functions`, `branch` | `lines = 100`; the rest are opt-in floors. A `branch` floor adds `--branch`, which runs on the nightly toolchain the crate pins in its own `rust-toolchain.toml` (with `llvm-tools-preview`). |

Related keys and surfaces:

* [`[rust] features`](/reference/config#rust-features) — cargo features the coverage run enables,
  so `#[cfg(feature = ...)]` code is compiled and measured.
* [`build_command`](/reference/config#build-command) — a pre-suite build the manifest can't
  express (a maturin/PyO3 extension).
* The floor is also published as a [shared test config](/reference/config#shared-test-configs)
  your own runner extends, so a local run is held to the same floor CI enforces.

A `coverage` exemption is **line-scoped, never whole-file**: the entry carries a `lines` list
naming the exact failing lines, with a required `reason`, and a determinism guard rejects a
listed line that isn't actually failing — see
[the exemption schema](/reference/config#line-scoped-exemptions).

## Learn more

* [Explanation — Coverage](/explanation/coverage): why 100, in full.
* [Respond to a red check](/guide/configure#relax-a-coverage-floor): lowering a floor, step by step.
