Skip to content

Isolation ​

Two checks — unit-lint and integration-lint — enforce one boundary from opposite sides. Isolation is about which collaborators a test may fake, and the answer is opposite for the two kinds of test:

  • A unit test isolates one unit. Everything it depends on is mocked — first-party collaborators and external packages alike — so a failure points at the unit, not a collaborator. A unit test that touches a real collaborator behaves like an integration test: slower, and ambiguous when it fails.
  • An integration test isolates the system. First-party code runs for real, and only the outside world is mocked. An integration test that mocks first-party code is testing a fiction: the assembled system it claims to exercise never actually runs.

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.

Where each check finds its subjects ​

The two checks split the tree by the standard's layout. unit-lint scans the colocated unit tests under the call's source, leaving <package root>/tests/ to the suite tiers. integration-lint derives its subjects from the package root — the nearest directory at or above source holding the language's manifest — per the standard layout: the integration suite in tests/integration/, the e2e suite in tests/e2e/. Both suites run first-party code for real, so both are held to the integration rules. (Rust's cargo layout keeps both out-of-crate suites in the crate root's tests/, so that whole directory is the subject there.) A test file under <package root>/tests/ outside a standard tier is flagged as unknown-tier: the layout is part of the standard, and a suite the scan would silently miss is an error instead. A tree with no manifest — loose scripts — is scanned at source directly.

What unit-lint 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.

What integration-lint flags ​

The integration suite's side: first-party code runs for real.

  • TypeScript — no-first-party-mock: a vi.mock() / vi.doMock() of a first-party module. Mocking stripe or node:fs is fine; mocking ../src/ledger is the violation.
  • Python — no-first-party-patch: a patch(...), patch.object(...), or patch.dict(...) whose target is the dist's own package. A string target is read directly; an object target is resolved through the file's imports — after from myproject import ledger, patch.object(ledger, "record") names myproject.ledger.record. An attribute reached through a first-party module is classified by what it names, read from that module's own top-level source: with async_mod.py holding import asyncio, patch.object(async_mod.asyncio, "to_thread") patches the stdlib asyncio module and passes; with it holding from . import helper, patch.object(async_mod.helper, "run") patches first-party myproject.helper and is flagged. Patching requests.post or subprocess.run is fine; patching ourpkg.ledger.record is the violation. Four hygiene lints ride along, keeping the mechanism of mocking disciplined: no-monkeypatch (use unittest.mock in a fixture, so patches are declared, not sprinkled), no-inline-patch (a patch lives in a fixture, not a test body), no-environ-mutation (set env via patch.dict(os.environ, ...), so it's restored), and no-constant-patch (inject config explicitly instead of patching a module global — patch("ourpkg.cfg.CACHE_DIR") and patch.object(cfg, "CACHE_DIR") are the same patch).
  • Rust — no-first-party-double: a #[double] (mockall_double) of the crate under test or a path dependency. Only external crates and std may be doubled.

A target that resists static reading is left alone — the checks are deterministic first. vi.mock(name) and patch(target) hold the target in a variable, and a patch.object / patch.dict base bound by no import (patch.object(get_mod(), "x"), a local variable, a fixture argument) resolves to no module, so neither is classified. The same holds one level deeper: a module attribute whose module's top-level source leaves it unnamed — a dynamic assignment, a conflicting binding, a source file the scan cannot find — resolves to no target and is left alone.

When a lint fires on a real design constraint ​

Every one of these rules is waivable per file with a reason-required exemption — and the bar for using one is high: what feels untestable usually needs a technique (inject the dependency, patch by string in a fixture, drive the boundary directly), not a waiver. The reasoned entry exists so that when a file genuinely earns one, the omission is named and auditable rather than silent.