Skip to content

Colocated tests ​

colocated-test is the first rung of the unit ladder: does a test exist? This page explains why the standard pins where that test lives and what it's named — and why, on a pull request, a changed source must change its test with it.

Why colocation ​

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; a test in the integration folder is an integration test. Nothing needs annotating, and the boundary can't drift, because it's the filesystem.

1:1 naming does the second job: an orphan can't hide. When every source file maps to exactly one test file, a source with no test is visible to a deterministic check — and so is a test whose source went away.

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 is deliberately omitted, with a reason.

Co-change: a stale test is an invisible orphan ​

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. So on pull requests the check also runs commit-scoped over the <base>...HEAD diff (Python, 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 coverage floor's concern.

Co-change reads the same subject definition presence does, from the file's own contents: a declaration-only module carries no behavior, so editing one is not a stale-test risk and needs no exemption. A module gains subject status on both halves of the check together, the moment it adds a function or control flow.

Changing a test on its own is always fine. Rust units are inline in the same file, so a sibling test can't go stale and the co-change check doesn't apply. A co-changeexemption lifts the check for a file, independently of the presence exemption.

Co-change and changed-line coverage are complementary: co-change enforces that the source and its test move together; the coverage floor enforces that the changed lines are exercised. One can pass while the other fails.

A modification is a change the compiler sees ​

What makes a modification a subject is the edit itself, not the file's appearance in the diff. The source at the merge base and the source at HEAD are both parsed, with comments and formatting whitespace normalized away, and the file is a subject when those two forms differ. A comment-only edit — rewording a # note, a // line, or a /* … */ block, or removing one outright — and a whitespace-only edit — a blank line, trailing spaces — compile to the same program, so the colocated test still pins the behavior the file has, and the edit passes on its own.

The normalization is deliberately narrow, and everything outside it stays a subject:

  • Python compares the parser's token stream, where a comment and a blank line never appear. A docstring is a string expression, so editing one is a code change, and indentation carries block structure, so moving a statement into the block above it is a code change.
  • TypeScript compares the parsed program with comments removed. Text inside a string or a template literal is code, so editing it is a code change.
  • A comment edit that travels with a code change is a code change: the code half makes the file a subject.
  • Content that fails to parse on either side counts as changed, so an unparseable file is held to its colocated test.

Both languages reach the same determination through their own parser, so the two arms agree on what an edit means — the parity the standard holds every check to.