Skip to content
Back to skills

Test Removal Criteria

ASecurity

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 deletion carries a written reason, redundant-coverage evidence from a per-test source map, and a named reviewer; a test that has merely never failed is treated as possibly load-bearing, not worthless. Use when a suite has outgr...

  • 10 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 12, 2026
testingtypescriptgotesting

Security analysis

A100/100

Pro scans all 4 files and shows the line behind each finding

Scanned October 12, 2026

npx -y skills add testland/qa --skill test-removal-criteria --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Test Removal Criteria?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Test Removal Criteria
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/testland-test-removal-criteria/badge)](https://www.skillsdirectory.com/skills/testland-test-removal-criteria)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: test-removal-criteria
description: "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 deletion carries a written reason, redundant-coverage evidence from a per-test source map, and a named reviewer; a test that has merely never failed is treated as possibly load-bearing, not worthless. Use when a suite has outgrown its signal value and someone is opening a pruning change set that deletes test files; for choosing which tests to RUN per change (not delete) use regression-suite-selector, and to track accumulating low-value coverage over time use the coverage debt ledger in qa-test-reporting's test-coverage-targeter."
---

# test-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](http://xunitpatterns.com/Production%20Bugs.html)).
A deleted test and a disabled test produce the same silence.

## How to use

1. Classify each candidate into exactly one removal class - `duplicate`,
   `tautology`, `trivial`, `dead-signal`, or `orphan` (Step 1); anything that
   fits no class is not a candidate.
2. Apply that class's test to confirm the classification (Step 2), honouring
   the never-remove list.
3. Run the four-condition delete gate; any failed or missing-input condition
   reverts the verdict to `keep` (Step 3).
4. Choose the action - `keep`, `rewrite`, `fold`, or `remove` (Step 4).
5. Record the pass as a ledger plus a kept table, packaged by confidence -
   templates and the confidence-to-action rules are in
   [references/removal-ledger-and-report-format.md](references/removal-ledger-and-report-format.md)
   (Step 5).

## The burden of proof sits on removal

Five rules govern every decision below. They are not advisory.

1. **Keep is the default verdict.** A test stays unless a removal case is
   affirmatively made. "No case was made either way" resolves to keep, never
   to delete.
2. **Every removal carries a stated reason and a named reviewer.** The reason
   names the removal class and the evidence; the reviewer is a person, not a
   team alias, who signed off on that specific row. A removal without both is
   not eligible to ship. (Practitioner convention, not a standard: teams that
   skip the named-reviewer column end up with rubber-stamped bulk deletes.)
3. **"It never fails" is not evidence a test is worthless.** A test that has
   passed for years while its subject churned may be the load-bearing guard
   that is passing *because the code is still correct*. Absence of failure is
   absence of evidence, in both directions.
4. **Coverage-tool output alone is not grounds for removal.** Coverage finds
   untested code; it does not measure test quality. Per Fowler, "if you make a
   certain level of coverage a target, people will try to attain it. The
   trouble is that high coverage numbers are too easy to reach with low
   quality testing"
   ([Fowler, TestCoverage](https://martinfowler.com/bliki/TestCoverage.html)).
   A redundant-coverage number is one input to the delete gate below, never
   the whole case.
5. **Never remove a test to make a signal go away.** Meszaros is explicit
   about the temptation with an intermittently failing test: "we may be
   tempted to remove the failing test from the suite to 'Keep the Bar Green'
   but this would result in an (intentional) Lost Test"
   ([Meszaros, Erratic Test](http://xunitpatterns.com/Erratic%20Test.html)).
   Flakiness is a diagnosis problem, not a removal class. Quarantine and fix.

## 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:

- It is labeled `@critical`, `@regression-guard`, or any team-configured
  do-not-delete marker.
- It has failed inside the signal window. A failure is the exact evidence
  that says this is a real test.
- A test-code quality review has flagged it for rewrite. Obscure or badly
  structured tests get refactored, not deleted: "test code is just as
  important as the production code and it needs to be refactored just as
  often" ([Meszaros, Obscure Test](http://xunitpatterns.com/Obscure%20Test.html)).
- It lives in vendored or third-party paths the team does not own.
- It is currently flaky (see rule 5).

## Step 2 - Apply the class test

Step 1 proposes a class from a signal; this step confirms it with a
class-specific test before the gate runs. The confirmation methods are precise
and easy to get wrong:

- **`duplicate`** - compare a normalized `(describe-path, normalized-setup,
  normalized-assertion-arguments)` signature, not assertion text; keep the
  co-located copy, and check for a `fold` (Step 4) first.
- **`tautology`** - confirmed when the expected side of the assertion calls
  into a production import (recomputes the subject), not by the literal shape
  `expect(x).toBe(x)`; the default action is **rewrite**, not remove.
- **`trivial`** - no assertion, or a self-satisfying one; add the missing
  assertion if the smoke value matters, otherwise remove.
- **`dead-signal`** - requires the 90-day per-test history minimum plus a
  three-question reviewer checklist, reviewed one test at a time, never batched.
- **`orphan`** - confirm the imported module or symbol is genuinely gone, not
  moved or re-exported, then remove and name the missing module.

The full method per class - normalized signatures, detection heuristics, code
examples, the dead-signal thresholds and reviewer checklist, and the supporting
citations (Meszaros, Fowler, Zhang and Mesbah) - is in
[references/applying-the-removal-classes.md](references/applying-the-removal-classes.md).

## 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`.

1. **No caught regression in the window.** The test has never gone from pass
   to fail to pass-after-fix inside the signal window. That transition is the
   signature of a test catching a real regression, and one occurrence is
   enough to end the discussion.
2. **Redundant coverage, proven per test.** Every source path this test covers
   is also covered by at least one other test, confirmed against a per-test to
   source-file map, not a merged coverage report. The bidirectional mapping
   this requires is the same one test impact analysis builds: "one test (from
   many) exercises a subset of the production sources" and "one prod source is
   exercised by a subset of the tests"
   ([Hammant and Fowler, The Rise of Test Impact Analysis](https://martinfowler.com/articles/rise-test-impact-analysis.html)).
   No per-test map means condition 2 cannot be evaluated, which means no
   removal in that pass. Merged line coverage cannot answer "which other test
   covers this line".
3. **No do-not-delete label.** Not `@critical`, `@regression-guard`, or any
   team-configured equivalent.
4. **Not flagged for rewrite.** A test that a test-code quality review marked
   as obscure, weakly asserted, or badly structured is a fix, not a delete.

Two properties of this gate matter more than the conditions themselves:

- It is **conjunctive and unweighted**. Three strong conditions do not
  outvote one failing condition. Do not convert it into a score.
- **A missing input is a failed condition, not a skipped one.** No signal
  history means condition 1 fails. No per-test map means condition 2 fails.
  Silence never counts in favor of removal.

## 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:

```typescript
// 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 class still passes Step 3 before anything is removed, and classes
go in separate change sets so a mix of high- and low-confidence rows never
trains the reviewer to skim. The per-class confidence-to-action table - which
classes may be batched, which are reviewed one at a time - is in
[references/removal-ledger-and-report-format.md](references/removal-ledger-and-report-format.md).

## Step 5 - Record and report

A removal pass produces exactly three artifacts, in the change description,
before anyone approves:

1. **The removal ledger** - one row per proposed removal, each with a class, a
   reason, four gate results, and a named reviewer.
2. **The kept table** - every classified candidate the gate stopped, with the
   failing condition named. It is how a reviewer checks the gate was run rather
   than asserted, and the record that explains, a year later, why a quiet test
   was left alone.
3. **A separate rewrite and fold list** - not removals, and they do not ship in
   the same change set as deletions.

The row templates for the ledger and the kept table are in
[references/removal-ledger-and-report-format.md](references/removal-ledger-and-report-format.md).
If the pass produces 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.

## 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.

## 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](https://martinfowler.com/bliki/TestCoverage.html)). | 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](http://xunitpatterns.com/Erratic%20Test.html)). | 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](http://xunitpatterns.com/Obscure%20Test.html)). | 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). |

## Limitations

- **A removal decision cannot be fully automated.** Classification is
  mechanical; the dead-signal checklist and the reviewer sign-off are not.
  Every rule here assumes a person reads the ledger.
- **No semantic understanding of value.** A test that scores as low-signal on
  every mechanical input may carry architectural or regression-guard value
  that no heuristic sees. This is why gate condition 3 depends on labels, and
  why unlabeled load-bearing tests are the standing risk: the team has to
  label them for the gate to protect them.
- **Redundancy evidence is only as good as the per-test map.** Tests that
  exercise a function indirectly through an integration path may not appear
  in a per-test coverage map at all, which makes redundancy look higher than
  it is and over-flags the dead-signal class.
- **Short history means no dead-signal decisions.** Under the 90-day minimum,
  the signal-driven classes are unusable. Duplicate, trivial, and orphan still
  work, because their evidence is structural rather than historical.
- **Removing tests does not make a suite fast.** It reduces count and
  maintenance surface. Per-test speed and per-change run size are different
  problems with different tools.

Files in this skill

  • .tessl-plugin/plugin.json941 B
  • SKILL.md17.6 KB
  • references/applying-the-removal-classes.md5.7 KB
  • references/removal-ledger-and-report-format.md3.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.