---
name: test-suite-consolidation
description: >
Clean up a test suite without changing what it proves — move fixtures and
helpers that were copied across test files into shared locations behind a
copy guard, or audit for unnecessary tests (orphans, phrase-pins, duplicate
coverage) and delete only the scope the user approves. Every change is
checked against a collected-test-ID diff.
when_to_use: >
Triggers on "test consolidation", "copied fixtures", "duplicate test
helpers", "prune tests", "unnecessary tests", "테스트 정리",
"불필요한 테스트". Do NOT activate on a slow suite with no duplication or
pruning ask (that is `test-suite-speedup`).
verified-against-runtime: true
runtime-verified-at: 2026-10-16
runtime-verified-note: "pytest 9.0.3 — `--collect-only -q -p no:cacheprovider | grep '::' | sort` gave one sorted id per test on a scratch project; pytest 9.1.1 with a file that fails to collect — the pipeline exited 0 without `set -o pipefail` and 2 with it. git 2.50.1 — `git archive origin/main tests/hooks | tar -x` extracted 30 files, `git log --diff-filter=A --format='%h %s'` named the adding commit. pre-commit 4.7.0 — with `.pre-commit-config.yaml` edited but unstaged, `git commit` exited 1 with `Your pre-commit configuration is unstaged`. `uvx ruff@0.15.8` resolved the pinned formatter. `gh issue create --body-file` checked via `--help` only; nothing was filed"
---
# test-suite-consolidation
## Overview
Test suites rot in two ways that look alike from a distance. Fixtures and
helpers get copied from the nearest file instead of reused, so the same body
lives in a dozen places and drifts; and tests accumulate that no longer earn
their keep — orphans, assertions that pin doc sentences nothing reads, and
duplicate coverage. Both are cleanups with zero intended behavior change, both
end in one issue and one PR, and both fail the same way: something that
mattered is silently removed. This skill runs either shape with a
collected-test-ID oracle and, for copies, a guard so the next change stops
re-copying.
**Core principle:** nothing a test proves may disappear unannounced — every
move is diffed against the collected test IDs, and every deletion is approved
by the user and re-verified by you.
Reference map — load only when the step runs:
| File | Holds |
| ------ | ------- |
| [`references/duplicate-audit.md`](references/duplicate-audit.md) | How to find copied fixtures/helpers on the base ref with a normalized-AST grouping, and the three buckets to report |
| [`references/copy-guard.md`](references/copy-guard.md) | Copy-guard design: binding-aware equality, diff-scoped vs baseline, thresholds, checker mutation testing, CI placement |
| [`references/pruning.md`](references/pruning.md) | Pruning shape: own-PR quick pass, criteria, deterministic probes, read-only fan-out, re-verification traps, implementation, report |
## When to Use
- The same fixture/helper body appears in several test files, or
per-directory conftests repeat the same overrides.
- The user asks whether the test structure is reusable, or wants other work
to stop patching tests locally.
- Adding or changing a CI/pre-commit guard against copied test code.
- The user asks which tests are unnecessary, or asks to clean them up —
including tests you wrote in your own open PR (→ the quick pass in
[`references/pruning.md`](references/pruning.md#own-pr-quick-pass)).
Pick the shape first: **consolidate** (Steps 1–8) or **prune**
([`references/pruning.md`](references/pruning.md)). A request for both runs
pruning first, so you do not consolidate helpers for tests about to be
deleted.
## Process — consolidating copied fixtures/helpers
### Step 1: Measure duplication on the base ref
Audit an extracted copy of `origin/`, not the working tree, and group
module-level fixtures/helpers by normalized body. Report three buckets
separately: (a) copies of a definition a shared conftest/helper already has —
safe deletes; (b) identical copies with no shared home — move; (c) same name,
different bodies — leave, and say why. Grep docstrings for "same pattern as
" and run `git log --diff-filter=A` on the copies: that chain is the
clearest evidence for the issue. Procedure →
[`references/duplicate-audit.md`](references/duplicate-audit.md).
Done when: every copy group is in exactly one bucket, with the copy-chain
evidence quoted.
### Step 2: Choose the shared-location model and state it
- **Fixtures stay in the conftest hierarchy.** Their reach and autouse
overrides are decided by directory; moving them into a `pytest_plugins`
module can change override order. Say this up front — the user may read
"one package" as including fixtures.
- **Plain helpers go into ONE package** under the tests root (e.g.
`/tests_common/{sql,import_stubs,servers}.py`, the Apache Airflow
`tests_common` shape). Scattered per-area helper modules are a transitional
step at most.
- **Call sites use the shared name directly.** An alias import
(`read_text as _read`) is acceptable only as an intermediate commit; finish
by renaming call sites in the same PR.
- **Moving per-directory autouse overrides into a parent conftest widens them
to sibling directories.** Grep the newly covered directories' tests for what
the overridden fixture provided (DB, connections) and state the result.
Done when: the PR body names where fixtures and where helpers live, and every
widened autouse override has its grep result stated.
### Step 3: Take the oracle before touching anything
```bash
set -o pipefail
pytest --collect-only -q -p no:cacheprovider | grep '::' | sort > /before.txt
```
`pipefail` makes a collection error fail the command instead of leaving a
partial `before.txt` behind `sort`'s exit 0; the file lives outside the
checkout so it never shows up in the diff.
After every step, regenerate and diff: only additions for new tests, zero
deletions. This catches what lint does not — a re-export removed by
`ruff check --fix` (F401), or a broken import from a sibling test module.
Done when: `before.txt` exists and is taken from the unmodified branch.
### Step 4: Apply mechanically, with refusal guards
- Delete a local def only when its normalized body equals the reference
exactly; report the skipped files as "differs". Fixtures that differ
slightly (an extra env patch, another schema) are not copies.
- Rename call sites with Python's `tokenize` (NAME tokens not preceded by
`.`), refusing any file where the shared name is already bound — a regex
`\b_old\b` also hits `obj._old`.
- Before renaming, grep for test modules importing the old private name from a
sibling test module (`from .test_x import _old`, including parenthesized
multi-line imports); the rename breaks those and only collection catches it.
- Lint and format only touched files, with the repo's pinned formatter version
(`uvx ruff@ format `), not the venv's.
- Delete helpers left with zero callers (`git grep ` shows only the
definition) in their own commit.
Done when: the collect-ID diff after each move shows zero deletions.
### Step 5: Add the copy guard
Prevent recurrence with a check, not only docs. Default design: diff-scoped
against a base revision (no baseline file), free-name-binding-aware equality,
fail on a redefinition of a shared definition or a 3rd test-module copy, run
as the last step of the existing test job plus a pre-commit hook. Mutation-test
every decision branch of the checker. Design, thresholds, the baseline
alternative and CI placement → [`references/copy-guard.md`](references/copy-guard.md).
Done when: each checker decision point has a mutation that fails at least one
test, and a historical replay includes one positive control that reports a
real copy.
### Step 6: Document the map
Add a "shared locations" table to the repo's agent guide (`AGENTS.md` /
`CLAUDE.md` testing section): what lives where, the import form, and how the
guard works. Agents read that file; it is the non-CI half of "stop local
fixes".
Done when: every shared module created or used by this PR appears in the table.
### Step 7: Verify
- Collect-ID diff, then the full suite in the CI's environment, plus any gated
subset in the environment that actually executes it (a separate venv or job
when main CI skips those tests).
- Path-filtered workflows that are the only place some tests execute must list
every new dependency of those tests in `paths:` (both `pull_request` and
`push`) — the new conftest, and the shared helper package once a test they
run imports from it. Otherwise a change to the shared file never triggers
the only job that runs it.
- When CI fails on the guard, reproduce the merge ref in a throwaway worktree
(`git worktree add --detach HEAD`, merge `origin/` there,
run the checker) — a base-branch merge can bring a new copy. Fix by merging
`origin/` into the branch (a merge commit, never a force-push), moving
the copy to the nearest common conftest, and collect-ID-diffing that
directory.
- If automated reviewers push to the PR, `git fetch` the head first and lint
their diff with the repo config: CI linters often lint only changed files,
so a docstring past the line limit fails there and nowhere locally.
- Blast radius: the guard applies to every open PR's next CI run. Count open
PRs touching the tests directory and say new copies there will fail.
Done when: the collect-ID diff is additions-only, the gated subsets ran in
their own environment, and the blast-radius count is in the PR body.
### Step 8: Commit and report
Split pre-existing format debt (files not clean under the pinned formatter on
the base) into a first `style(...)` commit, then one commit per logical move,
the guard, and docs. Report whether CI speed changed — consolidation usually
does not, because session-scoped setup already ran once. If asked, measure the
touched subset base vs branch in a throwaway worktree (2 rounds, drop the cold
first), and note that editing any `conftest.py` cold-starts a change-selection
cache keyed on `**/conftest.py` once after merge.
When asked whether the result is "top-tier", answer in a table with a source
per row: the pytest conftest hierarchy / `pytest_plugins` docs, the
PHPStan/detekt baseline model ("the life goal of a baseline is to not exist"),
and Airflow's single `tests_common` package. Name the remaining gaps (aliases,
scattered helper modules, a custom detector vs pylint/jscpd) and offer to
close them in the same PR.
Done when: `git log` shows the style commit first and no commit mixes a move
with unrelated format changes.
## Troubleshooting
| Symptom | Cause | Fix |
| --------- | ------- | ----- |
| Consumers fail collection after a clean lint | `ruff --fix` F401 deleted an import a module only re-exported | Import from the shared module at each consumer instead of re-exporting; the collect-ID diff catches it |
| Another test module breaks after a rename/move | It imported the helper or fixture from its old module | Grep `import .*\b\b` and multi-line import lists across the tests tree; drop the fixture from those imports once a conftest provides it |
| F401 / F811 on a shared fixture | The fixture was imported into another test module | Share fixtures through a conftest; import the module-local classes the body uses into that conftest — same module object, unchanged behavior |
| Pre-commit copy check fails on an unrelated commit | The tree holds a copy (e.g. from a base merge); pre-commit sees HEAD + staged | Commit the copy fix first; check `git show --stat HEAD` and split with `git reset --soft HEAD~1` if a failed commit left files staged |
| A "format-only" commit contains deletions | `git rm` stages immediately | Check `git diff --cached --name-status` before every split commit; redo with `git reset --soft HEAD~1` (local, unpushed) |
| pre-commit refuses to commit | `.pre-commit-config.yaml` has unstaged edits | Commit the config with the hook it adds, or restore it from HEAD temporarily for earlier commits |
| An edit did not happen | A chained command was blocked or denied, so none of its parts ran | Re-run the state-changing step alone before re-posting |
Answer the question as asked: when the user asks whether "the method in
" is right, evaluate that file's mechanism (CI placement, runner cost),
not adjacent logic.
## Integration
- `test-suite-speedup` — when the goal is time. Neither consolidation nor
pruning is a speedup by itself; measure before claiming one.
- `surface-enumeration` — when a pruned test was the only case for an input
variant, it is not a duplicate.
## Limitations
- The duplicate audit compares normalized text; two bodies that read the same
names but bind them to different definitions look equal until the copy
guard's binding-aware key is applied.
- Pruning verdicts from a read-only fan-out are claims; this skill makes you
re-verify them, but the re-verification is still reading, not execution —
the mutation check in the pruning pass is what proves a kept guard is pinned.