test-removal-criteria
Decides which existing tests should stop existing, using five removal classes (duplicate, tautology, trivial, dead-signal, orphan) and a four-condition delete gate where every condition must hold or the verdict reverts to keep. Puts the burden of proof on removal: each proposed deletion carries a written reason, redundant-coverage evidence from a per-test source map, and a named reviewer, and a test that has simply never failed is treated as possibly load-bearing rather than worthless. Covers removal only, not per-change test selection. Use when a suite has grown faster than its signal value and someone is about to open a pruning or suite-curation change set that deletes test files.
Install with skills.sh (any agent)
npx skills add testland/qa --skill test-removal-criteriatest-removal-criteria
The axis: removal, not selection
Two questions about a large test suite get confused, and they have very different risk profiles:
| Question | What it decides | Effect of a wrong answer |
|---|---|---|
| Which tests run on this change? | A per-change subset of an unchanged suite. | Recoverable: the skipped test still exists and runs on the next full pass. |
| Which tests should stop existing? | The contents of the suite itself. | Not recoverable in practice: the behavior stops being asserted, and nothing in CI announces the loss. |
This skill owns the second question only. Anything about narrowing a single run (impacted-set computation, previously-failing sets, fallback triggers, nightly safety runs) belongs to per-change selection and is deliberately out of scope here.
The asymmetry is the whole reason this skill is written conservatively. Meszaros names the end state of a bad removal a Lost Test, whose symptom is that "the number of tests being executed in a test suite has dropped (or has not increased as much as expected)", and lists it as a direct cause of Production Bugs: untested code has "no automated tests to tell the developer when they have introduced a problem" (Meszaros, Production Bugs). A deleted test and a disabled test produce the same silence.
The burden of proof sits on removal
Five rules govern every decision below. They are not advisory.
Step 1 - Classify the candidate into one removal class
Each candidate gets exactly one class. If two fit, take the more conservative one (the one lower in this table).
| Class | Signal that puts a test in this class | Default action |
|---|---|---|
duplicate | Two tests with the same normalized setup and the same normalized assertion arguments. | Keep one, remove the other. |
trivial | The body has no assertion at all, or its only assertion is self-satisfying (expect(true).toBe(true), expect(1).toBe(1), expect(undefined).toBe(undefined)). | Remove. |
orphan | The test imports a module or calls a function that no longer exists. | Remove, and name the missing module in the change description. |
tautology | The expected side of the assertion recomputes the behavior under test. | Rewrite to a known-good literal. Remove only if the rewrite has no value left. |
dead-signal | Zero failures across the signal window while the source files it covers have churned. | Human review only, per test. Never batched. |
A candidate that fits none of these five classes is not a removal candidate. "Old", "long", "slow", "nobody remembers why it is there", and "the coverage report says it is redundant" are not classes.
The never-remove list
Regardless of class, a test is off limits when any of these holds:
Step 2 - Apply the class test
duplicate
Compare a normalized signature, not assertion text. Cosmetic differences (toEqual vs toBe, reordered setup lines, renamed local variables) hide real duplicates, and identical text across different describe paths is often not a duplicate at all. The signature is the tuple:
(describe-path, normalized-setup, normalized-assertion-arguments)Two tests are duplicates only when all three match. Keep the copy in the canonical location, normally the file co-located with the subject under test; remove the stray copy left behind by a move or a copy-paste.
Before removing, check whether the pair is really a fold candidate instead (Step 4). Meszaros treats repeated test code as a smell whose fix is extraction and parameterization, not deletion (Meszaros, Test Code Duplication).
tautology
A tautology re-implements the subject under test inside the assertion, so it passes for any implementation:
// Tautology: the expected side redoes the arithmetic.
test('add adds', () => {
expect(add(2, 3)).toBe(2 + 3);
});
// Tautology: the expected side calls the subject under test.
test('formats price', () => {
expect(formatPrice(100)).toBe(formatPrice(100));
});
// Rewritten: a known-good value the test author committed to.
test('add adds', () => {
expect(add(2, 3)).toBe(5);
});Detection heuristic: the expected side of the assertion should contain only literals, expected-value constants, or test-fixture lookups. If it contains a call that resolves into a production module import, the assertion is tautological. Matching on the literal shape expect(x).toBe(x) is not the heuristic and misses nearly every real case, because the recomputation is usually spelled differently on each side.
The default action for this class is rewrite, not remove. The test names a behavior somebody cared about; only the assertion is broken. Remove only when the rewrite produces something already covered elsewhere, which turns it into a duplicate decision with its own evidence.
trivial
test('it works', () => {
expect(true).toBe(true); // self-satisfying
});
test('placeholder', () => {}); // no assertion at allThese are usually scaffolding left over from a test-first cycle. They execute code and contribute to coverage numbers while verifying nothing, which is exactly why coverage cannot be the deciding input: Fowler's account of an assertion-free suite notes "you can do this and have 100% code coverage, which is one reason why you have to be careful on interpreting code coverage data" (Fowler, Assertion-Free Testing). Empirical work points the same way: assertion quantity and assertion coverage correlate strongly with a suite's fault-detection ability measured by mutation score, and the relationship is not explained by suite size alone (Zhang and Mesbah, ESEC/FSE 2015, Assertions Are Strongly Correlated with Test Suite Effectiveness).
One caveat before removing: a body with no assertion still executes the code and can surface crashes. Fowler concedes "some faults do show up through code execution, eg null pointer exceptions" (Fowler, Assertion-Free Testing). If the smoke value is the point, the fix is to add the missing assertion, not to delete the test.
dead-signal
This is the class that most often gets removed wrongly. A test qualifies as a dead-signal candidate when it has recorded zero failures across the signal window while the source files it covers have churned repeatedly. Candidacy is not a verdict.
Typical thresholds, all practitioner conventions rather than standards, and all worth tuning per team: a signal window of 180 days for the no-failure check, at least 10 commits touching the covered files in that window, and a hard minimum of 90 days of per-test history before the class is used at all. Below that minimum there is no signal basis, and every candidate resolves to keep.
Reviewer checklist. Ask all three questions per test, in order:
Dead-signal removals are never batched. Each one gets its own reviewer sign-off, because the evidence is statistical and the failure mode is silent.
orphan
The test imports a module or calls a symbol that no longer resolves, normally the residue of a refactor that deleted the module but left the test behind, broken or skipped. Confirm the target is genuinely gone rather than moved or re-exported, then remove. Record the missing module in the reason so the reviewer can tell "the module went away" from "the import path went stale".
Step 3 - The delete gate: all four conditions must hold
Classification proposes. This gate disposes. For a removal to be eligible, every one of these must hold. If any one fails, the verdict is keep.
Two properties of this gate matter more than the conditions themselves:
Step 4 - Choose the action
Removal is one of four possible outcomes, and it is the last one to reach for.
| Outcome | When it applies |
|---|---|
keep | The gate failed on any condition, or the class was dead-signal and the reviewer checklist said keep. Also the outcome for every test not classified at all. |
rewrite | The test names a behavior worth asserting but asserts it badly: tautologies, missing assertions, obscure structure. |
fold | Several tests in the same describe path share setup and differ only in input data. Combine into one parameterized table. Folding reduces test-code maintenance surface without reducing coverage, so it is not a removal and does not need the gate. Do not fold across describe paths, and do not fold a labeled-critical test together with an unlabeled one: the fold hides the label. |
remove | The class test passed and all four gate conditions hold. |
A fold looks like this:
// Before: 4 tests, 4 setup blocks.
test('addItem accepts 1', () => { /* ... */ });
test('addItem accepts 5', () => { /* ... */ });
test('addItem accepts 100', () => { /* ... */ });
test('addItem rejects 0', () => { /* ... */ });
// After: 1 test, per-row failure messages preserved by the name template.
test.each([
{ qty: 1, expected: 'accepted' },
{ qty: 5, expected: 'accepted' },
{ qty: 100, expected: 'accepted' },
{ qty: 0, expected: 'rejected' },
])('addItem qty=$qty is $expected', ({ qty, expected }) => { /* ... */ });Confidence to action
Confidence is a property of the classification, not a license to skip the gate. Every row below still passes Step 3 before anything is removed.
| Class | Typical confidence | How it may be proposed |
|---|---|---|
duplicate | high | Batched into one change set, one removal per duplicate group, kept copy named per group. |
trivial | high | Batched into one change set, separate from other classes. |
orphan | high | Batched, with the missing module named per row. |
tautology | medium | Individually reviewed. Rewrite is the expected outcome; removal is the exception. |
dead-signal | low | Individually reviewed with per-test sign-off. Never batched, never proposed as a bulk delete. |
Keep classes in separate change sets. A single change set mixing high and low confidence rows trains the reviewer to skim, and the low-confidence rows are exactly the ones that need reading.
Step 5 - Record every removal
One row per proposed removal, in the change description, before anyone approves. This ledger is the artifact the rules above exist to produce.
| Test | Class | Reason | Gate 1 no regression | Gate 2 redundant coverage | Gate 3 no label | Gate 4 not flagged | Reviewer |
|------|-------|--------|----------------------|---------------------------|-----------------|--------------------|----------|
| `cart.spec.ts:34 Cart > addItem` | duplicate | Identical signature to `cart.spec.ts:12`; kept copy is the co-located one. | pass (0 fails, 365d) | pass (`src/cart.ts` L12-40 also covered by `cart.spec.ts:12`) | pass | pass | A. Rivera |
| `legacy/report.spec.ts:8 renders` | orphan | Imports `../src/reportBuilder`, deleted in 3f21ac9. | pass | n/a (test cannot run) | pass | pass | A. Rivera |Alongside it, state what the change set does not do:
**Kept despite classification**
| Test | Class | Why kept |
|------|-------|----------|
| `payment.spec.ts stripe_3ds_failure` | dead-signal | Gate 1 fail: caught a regression 2026-02-12. |
| `auth.spec.ts session_token_rotation` | dead-signal | Gate 3 fail: `@critical:auth-flow`. |
| `parseDate.spec.ts millennium_bug_edge` | dead-signal | Gate 2 fail: only test covering the pre-1970 branch. |The kept table is not filler. It is how a reviewer checks that the gate was actually run rather than asserted, and it is the record that explains, a year later, why a quiet test was left alone.
Worked example
A suite of 1,283 tests, 41 candidates surfaced, 214 days of per-test history.
| # | Candidate | Class | Gate result | Outcome |
|---|---|---|---|---|
| 1 | cart.spec.ts:34 duplicate of :12 | duplicate | all four pass | remove |
| 2 | add.spec.ts:5 expect(add(2,3)).toBe(2+3) | tautology | gate 4 fails: quality review flagged it | rewrite to .toBe(5) |
| 3 | smoke.spec.ts:2 expect(true).toBe(true) | trivial | all four pass | remove |
| 4 | report.spec.ts:8 imports deleted module | orphan | all four pass | remove |
| 5 | pricing.spec.ts:44 0 fails, covered files churned 22x | dead-signal | gate 2 fails: sole test covering the tax-rounding branch | keep |
| 6 | auth.spec.ts:19 0 fails, churn 14x | dead-signal | gate 3 fails: @critical:auth-flow | keep |
| 7 | checkout.spec.ts:60 intermittent failures | none (flaky) | not a removal class | quarantine and diagnose |
Result: 3 removals across two change sets (one for duplicate plus trivial, one for orphan), 1 rewrite, 3 tests kept with recorded reasons. Candidate 5 is the case the whole gate exists for: it looked like the strongest dead-signal row in the batch until the per-test map showed it was the only test on that branch.
Expected output shape
A removal pass produces exactly three artifacts:
If the pass produces a list of test names with no gate columns and no reviewer column, it is not a removal proposal. It is a list of suspicions, and nothing on it is eligible to be deleted.
Anti-patterns
| Anti-pattern | Why it fails | Instead |
|---|---|---|
| Treating a coverage report's "redundant" verdict as sufficient grounds | Coverage measures execution, not verification; high numbers are cheap to reach with weak tests (Fowler, TestCoverage). | Coverage is gate condition 2 only, and only from a per-test map (Step 3). |
| Bulk-deleting dead-signal tests because none of them ever failed | The always-passing test may be the load-bearing one; the next regression ships silently. | Per-test reviewer checklist, never batched (Step 2). |
| Deleting because the test is old | Age is not signal. The five-year-old test caught last month's regression. | Classify by class plus signal, not by date (Step 1). |
| Deleting a flaky test to get the build green | Produces an intentional Lost Test (Meszaros, Erratic Test). | Quarantine and diagnose; flakiness is not a removal class (rule 5). |
| Deleting an obscure or badly asserted test | The behavior it names is still worth asserting; test code is refactored like production code (Meszaros, Obscure Test). | Rewrite (Step 4). |
| Converting the four conditions into a weighted score | Three passes plus one fail becomes "mostly safe", and the one that failed was the coverage check. | Conjunctive gate: any fail means keep (Step 3). |
| Running the gate with no signal history or no per-test map, then proceeding | A missing input silently reads as "no objection". | Missing input equals failed condition (Step 3). |
| Matching duplicates on assertion text | Cosmetic differences hide real duplicates; identical text across describe paths flags false ones. | Normalized three-part signature (Step 2). |
Flagging tautologies by the literal shape expect(x).toBe(x) | Real tautologies spell the recomputation differently on each side. | Check whether the expected side calls into a production import (Step 2). |
| One giant quarterly change set | Reviewers rubber-stamp volume; the risky rows are invisible among the safe ones. | One change set per class, chunked by directory (Step 4). |
| Removing tests you can classify but do not own (vendored or third-party paths) | Proposes changes against code the team cannot maintain. | Never-remove list (Step 1). |