---
url: >-
  https://thekevinscott.github.io/testing-conventions/reference/checks/integration-lint.md
description: >-
  The integration-lint check — integration and e2e tests run first-party code
  for real; the suite tiers it scans, the per-language rules, the hygiene lints,
  and unknown-tier.
---

# `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`](./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](/monorepo#source-vs-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](/reference/workflow#inputs) names it `integration-lint`.

## Configuration

The check takes no keys of its own. Each rule is lifted per file by a
[`[[<language>.exempt]]` entry](/reference/config#exemptions) 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](/explanation/isolation): the boundary both lint checks enforce, as
  one essay.
* [Scoping and exemptions](/explanation/scoping): why the layout is part of the standard.
