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:
| 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 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: avi.mock()/vi.doMock()of a first-party module. Mockingstripeornode:fsis fine; mocking../src/ledgeris the violation. - Python —
no-first-party-patch: apatch(...),patch.object(...), orpatch.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 — afterfrom myproject import ledger,patch.object(ledger, "record")namesmyproject.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: withasync_mod.pyholdingimport asyncio,patch.object(async_mod.asyncio, "to_thread")patches the stdlibasynciomodule and passes; with it holdingfrom . import helper,patch.object(async_mod.helper, "run")patches first-partymyproject.helperand is flagged. Patchingrequests.postorsubprocess.runis fine; patchingourpkg.ledger.recordis the violation. Four hygiene lints ride along, keeping the mechanism of mocking disciplined:no-monkeypatch(useunittest.mockin 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 viapatch.dict(os.environ, ...), so it's restored), andno-constant-patch(inject config explicitly instead of patching a module global —patch("ourpkg.cfg.CACHE_DIR")andpatch.object(cfg, "CACHE_DIR")are the same patch). - Rust —
no-first-party-double: a#[double](mockall_double) of the crate under test or apathdependency. Only external crates andstdmay 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:
| Rule | Language | Flags |
|---|---|---|
no-first-party-mock | TypeScript | a vi.mock() / vi.doMock() of a first-party module |
no-first-party-patch | Python | a patch(...) / patch.object(...) / patch.dict(...) targeting the dist's own package |
no-monkeypatch | Python | mocking via monkeypatch instead of unittest.mock in a fixture |
no-inline-patch | Python | a patch in a test body instead of a fixture |
no-environ-mutation | Python | env mutated outside patch.dict(os.environ, ...) |
no-constant-patch | Python | patching a module-global constant (either patch form) instead of injecting config |
no-first-party-double | Rust | a #[double] of the crate under test or a path dependency |
unknown-tier | Python, TypeScript | a test file under <package root>/tests/ outside a standard tier |
Learn more
- Explanation — Isolation: the boundary both lint checks enforce, as one essay.
- Scoping and exemptions: why the layout is part of the standard.