Skip to content

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 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 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:

SpecifierOriginExample
relative / absolute pathfirst-party./service, ../core
the dist's own package (Python, from pyproject.toml [project].name; Rust, from Cargo.toml)first-partyourpkg.ledger
node:-prefixed or a builtin nameexternal (built-in)node:fs, child_process
any other bare specifierexternal (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. The gates input names it unit-lint.

Configuration ​

The check takes no keys of its own. Each rule is lifted per file by a [[<language>.exempt]] entry with a required reason:

RuleLanguageFlags
unmocked-collaboratorPython, TypeScripta collaborator the unit test imports and doesn't mock
untyped-mockTypeScripta mock factory with no vi.importActual<typeof import(...)>() type anchor
no-out-of-module-callRusta unit test calling out of its own module
no-out-of-module-importRusta 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.

Learn more ​