---
url: >-
  https://thekevinscott.github.io/testing-conventions/reference/checks/unit-lint.md
description: >-
  The unit-lint check — every collaborator in a unit test is mocked; the
  first-party/external line, the per-language rules, and their exemptions.
---

# `unit-lint`

A unit test isolates one unit: everything it depends on is mocked, so a failure points at the
unit, not a collaborator. `unit-lint` enforces that from the unit suite's side. This page is the
complete record of the check; [`integration-lint`](./integration-lint) enforces the same boundary
from the opposite side.

## Why this check exists

A unit test that touches a real collaborator behaves like an integration test: slower, and
ambiguous when it fails. The unit tier is built on failures that localize — and on staying fast
and deterministic enough for [mutation](./mutation) to re-run the suite many times. The check
makes that discipline deterministic instead of aspirational: a collaborator is either mocked, or
named in the log.

## The first-party/external line

Both checks turn on the same distinction, drawn deterministically with no module resolution:

| Specifier | Origin | Example |
| --- | --- | --- |
| relative / absolute path | first-party | `./service`, `../core` |
| the dist's own package (Python, from `pyproject.toml` `[project].name`; Rust, from `Cargo.toml`) | first-party | `ourpkg.ledger` |
| `node:`-prefixed or a builtin name | external (built-in) | `node:fs`, `child_process` |
| any other bare specifier | external (third-party) | `stripe`, `@scope/pkg` |

"External" means more than third-party packages: it includes **effectful standard-library APIs** —
the filesystem, the clock, randomness, the network, subprocess. An un-mocked external call is what
makes a test slow, flaky, or a charge on someone's bill, so the boundary is drawn there. Pure
stdlib (`json`, `dataclasses`, `std::collections`) is nobody's collaborator.

## What it flags

The unit suite's side: every collaborator is mocked.

* **TypeScript** — `unmocked-collaborator`: any runtime import a unit test doesn't `vi.mock()`.
  Named imports of the pure `parse` function from `json5` and `yaml`, and pure Node path
  functions `basename`, `dirname`, `extname`, `join`, and `normalize` from
  `node:path` or `path`, are value preparation rather than collaborators. The allowlist
  applies to those named imports; namespace, default, side-effect, and other imports
  from the same modules remain checked.
  Three imports are never collaborators: the unit under test (`widget.test.ts` → `./widget`),
  type-only imports, and the test runner (`vitest`). A mock matches its import by resolved module,
  so the extension may differ between the two — Vitest resolves `./formatter` and `./formatter.js`
  to the same module, so a `.js` import mocked bare (or the inverse) counts as mocked. Plus
  `untyped-mock`: a mock factory with no `vi.importActual<typeof import(...)>()` type anchor, so the
  double can't drift from the real module.
* **Python** — `unmocked-collaborator`: an imported collaborator the colocated `*_test.py` doesn't
  mock, both first-party and external (a third-party package, or effectful stdlib such as
  `socket`, `subprocess`, `random`). Never collaborators: the unit under test, the test framework,
  pure stdlib (`__future__` and the other underscore-prefixed modules included), and type-only
  imports. Pure parsers `ast.parse`, `tomllib.loads`, and `json.loads` are allowed from their
  standard-library modules. Third-party imports remain checked by default. The canonical unit
  test imports the unit under test and patches collaborators by string in a fixture. When a
  collaborator *is* imported, a `patch(...)` mocks it only when its target names that import's own
  module: `from pkg.ledger import record` is mocked by `patch("pkg.ledger.record")`, and each
  imported symbol must be patched — a `from pkg.ledger import record, erase` that patches only
  `record` leaves `erase` a real collaborator. A patch that merely shares a last segment with a
  different module (`patch("other.record")`, `patch("json.dumps")`) does not mock it. A re-export
  barrel is the unit under test too: in `__init___test.py`, a bare `from . import …` names the
  package's own `__init__.py` surface — `Thing`, `__all__`, `__version__` — so those names are what
  the test verifies, never collaborators. (Reaching around the barrel into a sibling module —
  `from .core import Thing` in `__init___test.py` — imports a real collaborator and is flagged.)
  This matches TypeScript, where `index.test.ts` importing `./index.js` is the unit under test.
* **Rust** — the same intent, structurally: `no-out-of-module-call` and `no-out-of-module-import`
  flag a unit test (an inline `#[cfg(test)]` module) that reaches out of its own module —
  `crate::…`, an external crate, or effectful `std` (`net`, `thread`, `os`, `process::Command`
  and its siblings, `env::var` and its siblings, the clock). Exact named pure construction and
  parsing paths are allowed:
  `clap::Command::new`,
  `clap::Arg::new`, `clap::Error::new`, `syn::parse_str`, `syn::parse_file`,
  `toml::from_str`, archive fixture constructors in `zip`, `flate2`, and `tar`, and
  `std::process::ExitStatus::from_raw`. Their named value-type imports are allowed
  separately; glob imports remain checked. Other calls and imports from those crates,
  including `std::process::Command`, remain checked. A single `super::` (the unit under test),
  `self`, and pure `std`
  stay in-module. Inject a trait double for a collaborator instead. **The filesystem is a
  Rust-specific carve-out**: `std::fs` and `std::env::temp_dir` stay in-module. Rust privacy
  is the reason. A private item is reachable only from its own module, so the inline
  `#[cfg(test)]` module is the *only* tier that can test a private path-walker — and that walker's
  argument is a directory that has to exist. Forbidding `fs` there forbids the one tier that can
  see the unit from exercising it. Python and TypeScript have no such constraint: a sibling test
  file reaches a module-private function directly, so its filesystem work can move out to the
  integration tier, and there it stays flagged. `std::env::var` is still a collaborator — it reads
  ambient state the test did not create; `temp_dir` only names a writable directory, and
  `process::id` — the other half of that naming idiom — only reports the runner's own PID. A unit test
  is a module gated by a positively-required
  `test` (`#[cfg(test)]`, `#[cfg(all(test, …))]`); a `#[cfg(not(test))]` module compiles in
  *non-test* builds, so it is production code and its out-of-module calls are left alone. The scan
  reads the crate's own unit source, skipping `tests/` integration crates and the `target/` build
  directory, so a locally-built crate is scanned the same as a fresh checkout.

A non-literal mock target (`vi.mock(name)`, `patch(target)`) can't be classified
deterministically and is left alone — the check is deterministic first.

## When it runs

Always, as a step of the `Static checks (<language>)` job, for Python, TypeScript, and Rust
alike. It scans the colocated unit tests under `source`, leaving `<package root>/tests/` to
[`integration-lint`](./integration-lint). The [`gates` input](/reference/workflow#inputs) names
it `unit-lint`.

## Configuration

The check takes no keys of its own. Each rule is lifted per file by a
[`[[<language>.exempt]]` entry](/reference/config#exemptions) with a required `reason`:

| Rule | Language | Flags |
| --- | --- | --- |
| `unmocked-collaborator` | Python, TypeScript | a collaborator the unit test imports and doesn't mock |
| `untyped-mock` | TypeScript | a mock factory with no `vi.importActual<typeof import(...)>()` type anchor |
| `no-out-of-module-call` | Rust | a unit test calling out of its own module |
| `no-out-of-module-import` | Rust | a unit test importing out of its own module |

The bar for exempting is high: what feels untestable usually needs a technique — inject the
dependency, patch by string in a fixture — not a waiver. See
[Scoping and exemptions](/explanation/scoping#the-bar-for-exempting).

## Learn more

* [Explanation — Isolation](/explanation/isolation): the boundary both lint checks enforce, as
  one essay.
* [Respond to a red check](/guide/configure#exempt-a-file): exempting a file, step by step.
