---
url: https://thekevinscott.github.io/testing-conventions/explanation/isolation.md
description: >-
  Why unit and integration tests mock opposite things — the first-party/external
  line, and what the two lint checks flag per language.
---

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

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

## 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](../guide/configure#exempt-a-file) — 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.
