---
url: >-
  https://thekevinscott.github.io/testing-conventions/reference/checks/colocated-test.md
description: >-
  The colocated-test check — every source file has a colocated, matching-named
  unit test, and on a pull request a changed source changes its test with it.
---

# `colocated-test`

Every source file under the scan root has a colocated, matching-named unit test — and on a pull
request, a changed source changes its test with it. This page is the complete record of the
check: why it exists, what it enforces per language, when it runs, and its configuration surface.

## Why this check exists

Colocation makes the unit/integration boundary **structural** — by location rather than a tag or
marker: a test that sits next to `widget.py` and is named `widget_test.py` is a unit test of
`widget`, by construction. 1:1 naming makes an orphan visible: when every source file maps to
exactly one test file, a source with no test is caught by a deterministic scan — and so is a test
whose source went away. This is the first rung of the
[unit ladder](/explanation/#the-unit-ladder-exist-→-run-→-verify) — does a test **exist**? — with
[coverage](./unit-coverage) and [mutation](./mutation) asking the stronger questions above it.

The co-change mode exists because presence isn't enough on a pull request: a source edit that
leaves the colocated test untouched lets the test silently go stale — it still exists, but it
pins the old behavior.

## What it enforces

The check is **tree-wide presence**: every source file under the scan root has its colocated,
matching-named unit test.

A source file is a **subject** when it holds a function or control flow anywhere in it — inside a
class body or an object literal included. A function has inputs and a branch depends on something,
so a test can drive either and fail for a reason the source does not already state. A
**declaration-only module** holds neither: it runs once, at import, and importing it is the whole
test. It is skipped. The parser decides this from the file's contents, so no file name is special
and no exemption is involved; a module becomes a subject the moment it gains a `def`, an arrow, a
method, an `if`, or a ternary.

* **Python** — `foo.py` → `foo_test.py`, side by side. A function is `def`, `async def`, or
  `lambda`; control flow is `if`, `for`, `while`, `try`, `with`, `match`, a conditional
  expression, or a comprehension with an `if`. A re-export barrel `__init__.py`, a constants
  module (`TIMEOUT = 30 * 60`, `logger = getLogger(__name__)`), an `Enum`, a dataclass of
  fields, a `TypedDict`, and an annotation-only class are declaration-only. A class is a subject
  through its methods.
* **TypeScript** — `foo.ts` / `.tsx` / `.mts` / `.cts` → a colocated `foo.test.*` of the matching
  extension. A function is a function declaration, a function expression, an arrow, or a method,
  each with a body (a `declare` or overload signature compiles to nothing); control flow adds
  `switch`, `do`, and the ternary. A re-export barrel (`export { x } from './x'`,
  `export * from './x'`), an `export const` of literals, an `as const` object, an enum-only
  module, and a type-only module (`type` / `interface` / `import type` / `export type`) are
  declaration-only. Declaration files (`*.d.ts`) are ignored outright.
* **Rust** — units are inline `#[cfg(test)]` modules, not sibling files, so the check is presence
  of the inline module: a `src` file that defines a function with a body but has no `#[cfg(test)]`
  module is an orphan. A test module is one gated by a positively-required `test` — `#[cfg(test)]`
  or `#[cfg(all(test, …))]`; a `#[cfg(not(test))]` module compiles in *non-test* builds, so it is
  production code and counts as behavior to test, never as the inline test. Module-declaration
  files (only `mod` / `use`) and type-only files (no `fn`) aren't subjects; `tests/`, `benches/`,
  `examples/`, and `build.rs` are skipped.

`and`, `or`, `||`, `&&`, and `??` short-circuit but are not control flow, so
`PORT = env.PORT ?? 3000` stays declaration-only. Empty or comment-only files are never subjects,
and a file with a `colocated-test` [exemption](/guide/configure#exempt-a-file) is deliberately
omitted, with a reason.

### Co-change, on pull requests

On pull requests the check also runs **commit-scoped** over the `<base>...HEAD` diff (Python and
TypeScript):

* a **modified** source must have its colocated test in the diff too;
* a **deleted** source that had a test in the base tree must delete or update that test with it;
* an **added** source is not a subject — brand-new code is the
  [changed-line coverage floor](./unit-coverage#the-changed-line-job)'s concern.

A modification is a subject when it changes code the compiler sees. The file at the merge base and
the file at `HEAD` are parsed and compared with comments and formatting whitespace normalized away,
so a `#` / `//` / `/* … */` comment edit, a blank-line change, and a trailing-whitespace change
compare equal and pass on their own — the colocated test still pins the behavior the file has. Text
inside a string literal, a Python docstring, and a TypeScript template literal is code, as is the
block structure Python indentation carries, so an edit there is a subject; so is a comment edit
that travels with a code change. Content that fails to parse on either side counts as changed and
is held to its test.

Changing a test on its own always passes. Rust units are inline in the same file, so a sibling
test can't go stale and co-change doesn't apply to Rust — a deliberate asymmetry.

### A Rust binary's entry point

Rust requires a `main` in a binary root, so `src/main.rs` cannot be emptied the way a library module
can. It needs no exemption. Two shapes read as declaration-only; both move the logic into the
library, where a test reaches it.

**Re-export the entry point.** A `pub use` carries no function at all:

```rust
// src/main.rs
pub use mycrate::entrypoint::main;
```

Rust accepts a re-export as the entry point as long as the item is a zero-argument function
returning a `Termination` type.

**Or delegate in a single call.** A `fn main` is declaration-only when its body is one
argument-free call and nothing else:

```rust
// src/main.rs
#[cfg(not(test))]
fn main() -> std::process::ExitCode {
    mycrate::entrypoint::main()
}
```

The shape is deliberately narrow, because anything wider can hide a decision. Exactly one
statement, and that statement a call through a path taking no arguments. A second statement, an
argument, an operator, a `?`, a `match`, a method call — each makes the file a subject again, and
then it needs its own inline `#[cfg(test)]` module.

`#[cfg(not(test))]` is not optional on this shape. Without it the function is instrumented and
reads 0% — `cargo test` replaces a binary's `main` with the harness's own, so no unit test can ever
execute it, and a 100% floor would be unreachable. The re-export needs no such attribute of its
own, because a `pub use` emits no code regions. It does link the library's gated `main` into the
binary target's test harness, where `cfg(test)` is unset and the item is compiled and instrumented;
[`unit-coverage`](./unit-coverage#an-item-the-test-build-never-compiles) drops a gated item from the
ratios, so both shapes measure the same.

That leaves one line outside the unit tier by construction — the process-boundary argv read the
library's `main` performs. Keep it free of decisions, mark it `#[cfg(not(test))]` too, and let
[`e2e-verify`](./e2e-verify) exercise the real binary.

## When it runs

| Mode | Runs | As |
| --- | --- | --- |
| Presence | always, tree-wide | a step of the `Static checks (<language>)` job |
| Co-change | pull requests only (Python, TypeScript), over `<base>...HEAD` | a step of the same job |

The scan covers every file under `source`, leaving `<package root>/tests/` to the suite tiers.
The [`gates` input](/reference/workflow#inputs) names it `colocated-test`; the diff-scoped
co-change mode rides with it.

## Configuration

The check takes no keys of its own. Its exemption names, each a
[`[[<language>.exempt]]` entry](/reference/config#exemptions) with a required `reason`:

| Exemption | Lifts |
| --- | --- |
| `colocated-test` | the presence requirement for one file (a launcher shim, a process entry point) |
| `co-change` | the co-change requirement for one file, independently of presence |

Both exemptions apply to whole files. A declaration-only module is never a subject, and a comment-only or
whitespace-only edit is never a co-change subject — the files themselves decide both, with no
configuration.

## Learn more

* [Explanation — Colocated tests](/explanation/colocated-test): why colocation, and why a stale
  test is an invisible orphan.
* [Respond to a red check](/guide/configure#exempt-a-file): exempting a file, step by step.
