Skip to content

integration-lint ​

An integration test isolates the system: first-party code runs for real, and only the outside world is mocked. integration-lint enforces that from the suite's side. This page is the complete record of the check; unit-lint enforces the same boundary from the opposite side.

Why this check exists ​

An integration test that mocks first-party code is testing a fiction: the assembled system it claims to exercise never actually runs. The check keeps the integration tier honest — and it guards the suite layout itself: a test file outside a standard tier is a suite the scan would silently miss, so it is named as an error (unknown-tier) instead.

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 it finds its subjects ​

Subjects derive from the package root — the nearest directory at or above source holding the language's manifest: the integration suite in tests/integration/ and the e2e suite in tests/e2e/ (Rust: the crate root's tests/, cargo's own layout, holding both out-of-crate suites). Both suites run first-party code for real, so both are held to the integration rules. A test file under <package root>/tests/ outside a standard tier is flagged as unknown-tier; a tree with no manifest — loose scripts — is scanned at source directly.

What it 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 mock target that resists static reading is left alone — the check is 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 it runs ​

Always, as a step of the Static checks (<language>) job, for Python, TypeScript, and Rust alike. The gates input names it integration-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 — and for this check's suite subjects, the entry's path resolves relative to the package root the tiers derive from (e.g. tests/integration/billing_test.py), not the scanned source:

RuleLanguageFlags
no-first-party-mockTypeScripta vi.mock() / vi.doMock() of a first-party module
no-first-party-patchPythona patch(...) / patch.object(...) / patch.dict(...) targeting the dist's own package
no-monkeypatchPythonmocking via monkeypatch instead of unittest.mock in a fixture
no-inline-patchPythona patch in a test body instead of a fixture
no-environ-mutationPythonenv mutated outside patch.dict(os.environ, ...)
no-constant-patchPythonpatching a module-global constant (either patch form) instead of injecting config
no-first-party-doubleRusta #[double] of the crate under test or a path dependency
unknown-tierPython, TypeScripta test file under <package root>/tests/ outside a standard tier

Learn more ​