---
url: https://thekevinscott.github.io/testing-conventions/reference/config.md
description: >-
  The testing-conventions.toml schema — every coverage key and its default, the
  exemption schema, and the shared test configs.
---

# Configuration

One TOML file is the single source of truth for what the checks and rules require: coverage floors and
reason-required exemptions. This page is the canonical record of its schema and every default.
For the task, see [Respond to a red check](../guide/configure); for the design, [Scoping and
exemptions](../explanation/scoping); for the per-check view — every key and exemption name that
touches one check, on that check's page — see [Checks](./checks/).

The file is named by the workflow's `config` input (default `testing-conventions.toml`, resolved
per call — a [monorepo](../monorepo) passes one per package). The loader validates the schema:
unknown keys, malformed TOML, and reason-less `exempt` entries are rejected. An unknown-key
error names the key, lists the accepted keys, and points at the package's
[`MIGRATIONS.md`](https://github.com/thekevinscott/testing-conventions/blob/main/packages/rust/MIGRATIONS.md),
which records every key a release renamed or removed alongside its replacement. Each `[python]` /
`[typescript]` / `[rust]` table is optional, and so is every key within it.

```toml
[python]
coverage = { branch = true, fail_under = 100 }
# The longest body a function may have and still share a file (default 1):
one_function_per_file = { max_lines = 5 }

# A whole-file presence exemption:
[[python.exempt]]
path = "mypkg/cli.py"
rules = ["colocated-test"]
reason = "thin launcher; logic in run(), tested in run_test.py"

# A line-scoped coverage/mutation exemption — `lines` is required, and never
# shares an entry with a whole-file check:
[[python.exempt]]
path = "mypkg/config/tomlcompat.py"
rules = ["coverage", "mutation"]
lines = [9, 10, "12-13"]
reason = "version-conditional tomllib/tomli import; one branch is dead on any single interpreter"

[typescript]
coverage = { lines = 100, branches = 100, functions = 100, statements = 100 }

[rust]
# Cargo features the suite-running Rust checks enable, so `#[cfg(feature = ...)]`
# code is compiled, measured, and mutated:
features = ["cli"]
coverage = { regions = 100, lines = 100 }
```

## The language tables

`[python]`, `[typescript]`, and `[rust]` take the same keys, one Rust-only addition aside. Each key
has its own section below.

| Key | Meaning |
| --- | --- |
| `coverage` | The language's coverage floor. |
| `one_function_per_file` | The `one-function-per-file` threshold. |
| `exempt` | The exemption entries, each its own `[[<language>.exempt]]` table. |
| `build_command` | The build declaration a build-dependent job runs. |
| `reason` | A free-form note on the table's `build_command`, kept as written. An exemption entry's required `reason` is a separate field. |
| `features` | `[rust]` only: the cargo features the suite-running Rust checks enable. |

`[e2e]` is the fourth root table, language-agnostic, carrying `extra_scope` and `exclude`.

## Coverage

Coverage floors apply to the **unit suite only** (test files are excluded from the denominator).
Each language's default is a strict 100% floor; a `[<language>].coverage` table lowers it as a
**partial override** — set only the fields you want to move, the rest keep their default, and a
typo'd key is rejected. [Why a 100% floor](../explanation/coverage) carries the rationale,
including why Rust's extra metrics are opt-in.

| 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`; `regions`, `functions`, and `branch` are opt-in floors. A `branch` floor adds `--branch` to the `cargo llvm-cov` run, which needs a nightly toolchain (pin one in `rust-toolchain.toml` with `llvm-tools-preview`, or set a rustup directory override); on stable the run fails with the requirement named. |

`mutation` has **no percentage key** — the gate is binary, not a score, and config can't
loosen it. Its only tuning is a line-scoped `mutation` exemption (below); see
[Why mutation testing](../explanation/mutation#why-a-number-wont-do-equivalent-mutants).

## `one_function_per_file`

Each language table takes **`one_function_per_file`**, whose one key sets the threshold the
[`one-function-per-file`](../explanation/one-function-per-file) check counts against. Python and
TypeScript apply the check with no table present; **Rust applies it only when the table is there**,
because a Rust file is a module and grouping functions in one is idiomatic — see
[Rust is off until you opt in](../explanation/one-function-per-file#rust-is-off-until-you-opt-in):

| Key | Meaning | Default |
| --- | --- | --- |
| `max_lines` | The longest body a module-scope function may have and still **share** a file. A function longer than this must be the file's only such function. | `1` — a one-line function is trivial and shares freely. |

Like `coverage`, the table is a **partial override** and a typo'd key is rejected; an absent table
means the default. A function's length is the count of code lines in its body — blank lines,
comments, and a Python docstring don't count, and neither does the signature.

```toml
[python]
one_function_per_file = { max_lines = 5 }

[typescript]
one_function_per_file = { max_lines = 20 }
```

Raising `max_lines` moves the boundary between "trivial enough to share" and "substantial enough to
own the file"; it never turns the check off. A file whose functions genuinely belong together takes a
`one-function-per-file` exemption below.

## Exemptions

A deliberate omission is a `[[<language>.exempt]]` entry:

| Field | Meaning |
| ----- | ------- |
| `path` | The exempt file, **relative to the scanned `source`** of the call that loads this config — except for `integration-lint`'s suite subjects, which resolve **relative to the [package root](../monorepo#source-vs-the-package-root)** the tiers derive from (e.g. `tests/integration/billing_test.py`). Must point to a file that exists; a stale entry is a hard error, so the list can't silently rot. |
| `rules` | Which exemptable units the entry lifts: check ids (`colocated-test`, `one-function-per-file`, `coverage`, `mutation`) and rule ids (the mocking, isolation, and layout assertions listed in the check pages). `co-change` names the diff-scoped mode of `colocated-test`. See the [glossary](./glossary#the-rules-config-key-is-a-mixed-namespace). |
| `lines` | The lines a `coverage` / `mutation` exemption covers. **Required** with `coverage` / `mutation`, **rejected** with any other exemption name. |
| `reason` | Why the omission is deliberate. **Required**: an empty reason is rejected on load. |

### Line-scoped exemptions

The measured-line checks — `coverage` and `mutation` — are never whole-file: their entries carry a
`lines` list naming the exact lines they cover. Each element is a 1-based line number (a TOML
integer) or an inclusive range (a `"start-end"` string). A **determinism guard** checks the list:

* A listed line that **isn't failing** — covered (`coverage`), its mutants all caught
  (`mutation`), or carrying no measured code — is a **hard error**.
* A failing line that **isn't listed** fails the gate as normal.

So the list is exactly the failing lines. The two entry kinds never mix: an entry naming
`coverage` / `mutation` without `lines` is rejected on load, a `lines` key alongside a whole-file
exemption is rejected, and a file exempt from both `colocated-test` and `coverage` is two entries.
Under the diff-scoped mutation job, a listed line outside the diff isn't mutated and is left
alone; the guard fires only on a listed line whose mutants were run and all caught.

### Automatic exemptions

Two kinds of files are skipped with no configuration — the only non-explicit exclusions:

* **Declaration-only modules** — a file with no function and no control flow anywhere in it: an
  empty or comment-only file, a re-export barrel `__init__.py` / `index.ts`, a constants module,
  an `Enum`, a type-only module. The parser decides from the file's contents, never from its name;
  the moment a file gains a `def`, an arrow, a method, an `if`, or a ternary, it becomes a
  subject. [What it enforces](../explanation/colocated-test#what-it-enforces) carries the node
  list per language.
* **Declaration files** (`*.d.ts` / `*.d.mts` / `*.d.cts`) — they carry no runtime code.

## `[rust] features`

The `[rust]` table takes **`features`**, a list of cargo features the suite-running Rust checks
enable: `unit-coverage` passes it to `cargo llvm-cov` as `--features`, and `mutation` passes it
to cargo-mutants as `--features`, so `#[cfg(feature = ...)]` code is compiled, measured, and
mutated. The mutation run enables the features on **every** cargo invocation it makes — the build
of the crate's test targets as much as the test run itself — so an integration test that names a
feature-gated item compiles and judges the mutants of the code it covers. Cargo features are Rust's
build-system concept with no Python/TypeScript analog, so the key is deliberately Rust-only — a
documented asymmetry under the [parity principle](../explanation/#parity-over-cleverness).

## `build_command`

Each language table — `[python]`, `[typescript]`, `[rust]` — takes **`build_command`**, a shell
command that runs at the package root — a relative path in the command resolves from there — in
four jobs: `unit-coverage`, changed-line coverage, `mutation`, and `packaging`. Each of those jobs
runs it after its own detected-language toolchain and dependencies are set up, and **before** it
builds or imports the package. It **supplies a necessary fact** — how to build a package the
ecosystem doesn't build for you — for a build the manifest **structurally can't express**: where an
ecosystem standardizes the build, the tool derives it (a maturin/PEP 517 backend, Cargo's
`build.rs` and `cargo package`, npm's `prepare` / `prepack` run by `npm pack`), and `build_command`
names only the remainder — never a heuristic that guesses a script name that isn't standardized.

It is **not an escape hatch** and requires no justification: unlike `gates` (which skips a check) or
`rust_toolchain` (which overrides a working default), it waives nothing — it just names the build.
So it carries **no required `reason`**; an optional `reason` note is retained if you want to explain
the build, but a bare `build_command` loads.

```toml
[typescript]
build_command = "pnpm build"
```

The workflow discovers `build_command` in the package's own `testing-conventions.toml` at the
[package root](../monorepo), exactly like the config file itself — a config key, not a `uses:`-call
input, and one per language table so a package names only the build its own language needs.

Where each language reaches for it:

* **Python** — the common case: a unit suite that imports a compiled module (a maturin/PyO3
  extension) needs it built first, and PEP 517 backends expose only sandboxed `build_wheel` /
  `build_sdist` hooks with no manifest-declared pre-build shell step
  (`build_command = "uv run maturin develop"`).
* **TypeScript** — for packaging, when the compile-before-pack lives in a script npm doesn't run on
  `pack`. npm standardizes `pack` (which runs `prepare` / `prepack`) but not the build script's name
  — it's `build` in one package and `compile` in the next — so the tool can't derive it, and this
  one line names it.
* **Rust** — rarely: `cargo` compiles via `build.rs` and packages via `cargo package` from the
  manifest, so `build_command` is only for a pre-build step neither expresses.

### A command naming another ecosystem's build

Each of the four jobs provisions the toolchain and dependencies of its **own detected language**
only. A `build_command` that reaches into a sibling package written in a different language names
that ecosystem's own setup itself, inline, as part of the command.

Node's runtime is on `PATH` in every one of the four jobs regardless of language — the reusable
workflow installs it unconditionally to run the check tooling itself. A package manager beyond that
bare runtime, a sibling package's own dependency install, and a build tool the runner doesn't
preinstall (`just`, for one) are the command's own to arrange:

```toml
[python]
build_command = "corepack enable && pnpm --dir ../node install --frozen-lockfile && pnpm --dir ../node build"
```

`corepack enable` puts pnpm on `PATH`, using the Node runtime already there; `pnpm --dir ../node`
points each command at the sibling package without a `cd`, resolved relative to the package root
the command itself runs from.

## `[e2e] extra_scope` and `exclude`

The `[e2e]` table takes **`extra_scope`**, a list of repo-root-relative directories outside the
package's own subtree whose changes join the [`e2e-verify`](../explanation/e2e#a-shared-source-tree-beside-the-package)
scoped diff, and **`exclude`**, a list of feature-gated subtrees carved back out of that union.
It is the declaration for a package whose e2e artifact is compiled from a **shared source tree that
sits beside it** — a native core bound into several language bindings — which no `--scope`
at-or-below the package root can reach.

```toml
[e2e]
extra_scope = ["packages/rust/src"]
exclude = ["packages/rust/src/cli", "packages/rust/src/bin"]
```

The value is discovered in the package's own `testing-conventions.toml` at the
[package root](../monorepo), exactly like the config file itself — a fact about the package's build
(*my artifact is compiled from that tree*), not a `uses:`-call input. `detect` renders the lists as
repeated `--extra-scope` / `--exclude` arguments and the `e2e-verify` job appends them; a package
declaring neither scopes the diff to its own `source` alone. Both are lists of directory paths, so a path
with a space is not supported. This is git-level and language-agnostic — it holds across Python,
TypeScript, and Rust identically. Like `build_command`, the tool's own config loader never acts on
these keys — it accepts the table so the rest of the config still loads — while `detect` and the
workflow read them.

## Shared test configs

The coverage floor is also published as a config your own test runner extends, so a local run is
held to the same floor CI enforces:

* **TypeScript** — the npm package exports `vitestConfig` from its root; extend it with
  `mergeConfig` from `vitest/config`. It carries the v8 provider, the `src/**` coverage scope
  (declaration files excluded), the `100/100/100/100` thresholds, and `src/**/*.test.ts` as the
  test include, so a local run collects the same colocated unit suite the checks scan. It also
  declares the `text` coverage reporter with `skipFull: false`, so the coverage table lists every
  file at the floor: Vitest 4 injects `skipFull: true` into the text reporter when an agent
  environment variable is present, and at 100% across the board that omits every row — the
  declared value wins. `mergeConfig` concatenates reporter lists, so declare only the reporters
  you add (`reporter: ['lcov']`) and the base's `text` entry stays. `vitest` is an optional peer
  dependency, and the import resolves to the library entry, separate from the CLI.
* **Python** — the `testing-conventions` wheel auto-loads a pytest plugin that holds a
  `pytest --cov` run to the same floor: branch coverage on, `fail_under = 100`, and test files
  (`*_test.py` / `conftest.py`) omitted from the denominator. It acts only when a coverage run is
  active, and your own setting always wins — set `branch`, `fail_under`, or `omit` in any coverage
  config location and the plugin leaves it alone.
