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:
| 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'tvi.mock(). Named imports of the pureparsefunction fromjson5andyaml, and pure Node path functionsbasename,dirname,extname,join, andnormalizefromnode:pathorpath, 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./formatterand./formatter.jsto the same module, so a.jsimport mocked bare (or the inverse) counts as mocked. Plusuntyped-mock: a mock factory with novi.importActual<typeof import(...)>()type anchor, so the double can't drift from the real module. - Python —
unmocked-collaborator: an imported collaborator the colocated*_test.pydoesn't mock, both first-party and external (a third-party package, or effectful stdlib such assocket,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 parsersast.parse,tomllib.loads, andjson.loadsare 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, apatch(...)mocks it only when its target names that import's own module:from pkg.ledger import recordis mocked bypatch("pkg.ledger.record"), and each imported symbol must be patched — afrom pkg.ledger import record, erasethat patches onlyrecordleaveserasea 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 barefrom . import …names the package's own__init__.pysurface —Thing,__all__,__version__— so those names are what the test verifies, never collaborators. (Reaching around the barrel into a sibling module —from .core import Thingin__init___test.py— imports a real collaborator and is flagged.) This matches TypeScript, whereindex.test.tsimporting./index.jsis the unit under test. - Rust — the same intent, structurally:
no-out-of-module-callandno-out-of-module-importflag a unit test (an inline#[cfg(test)]module) that reaches out of its own module —crate::…, an external crate, or effectfulstd(net,thread,os,process::Commandand its siblings,env::varand 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 inzip,flate2, andtar, andstd::process::ExitStatus::from_raw. Their named value-type imports are allowed separately; glob imports remain checked. Other calls and imports from those crates, includingstd::process::Command, remain checked. A singlesuper::(the unit under test),self, and purestdstay in-module. Inject a trait double for a collaborator instead. The filesystem is a Rust-specific carve-out:std::fsandstd::env::temp_dirstay 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. Forbiddingfsthere 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::varis still a collaborator — it reads ambient state the test did not create;temp_dironly names a writable directory, andprocess::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-requiredtest(#[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, skippingtests/integration crates and thetarget/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:
| 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.
Learn more
- Explanation — Isolation: the boundary both lint checks enforce, as one essay.
- Respond to a red check: exempting a file, step by step.