Deciding When to Delete a Test

Test suites only ever grow, because adding a test is virtuous and removing one feels like reducing safety. The result is suites where a third of the tests protect nothing: duplicates of a neighbour, assertions on removed behaviour, browser-level checks of logic that a unit test already pins, and flaky tests that everybody has learned to re-run. This guide gives a set of criteria for identifying those, a safe procedure for removing them, and a way to demonstrate that coverage of behaviour — not of lines — was preserved. It is for engineers with permission to prune and a suite that has outgrown its usefulness, and it sits under cost-benefit analysis of test layers.

Root Cause Analysis

Tests accumulate faster than they earn their keep for structural reasons. A bug fix arrives with a regression test, which is right, and the test stays after the code around it is rewritten three times. A feature is moved from one tier to another and tested in both, because deleting the old one was not part of the change. A refactor makes two tests equivalent without anyone noticing, since neither author was looking at the other.

The reason nobody prunes is asymmetric risk, as it appears at the moment of decision. Deleting a test carries a small, vivid, personal risk — if something breaks, you deleted the test that would have caught it. Keeping it carries a large, diffuse, shared cost that nobody attributes to anyone. Left to instinct, that asymmetry guarantees growth.

Making the decision mechanical is what removes the asymmetry. If the criteria are written down and agreed, deleting a test that meets them is following the policy rather than taking a personal risk, and that single change is usually what unblocks pruning in a team that has been meaning to do it for a year.

Four kinds of test that can usually go Duplicates of a sibling, tautologies that restate the implementation, checks duplicated at a more expensive tier, and long-quarantined flaky tests nobody owns — each has a distinct signature. duplicate of a sibling same inputs, same assertion, different name tautology recomputes the expectation the way the code does tested twice, expensively a browser check of logic a unit test already pins quarantined and unowned past its deadline with nobody investigating
Each category has a mechanical signature, which is what turns pruning from a judgement call into a policy.

Reproducible Setup

Two cheap analyses find most candidates: a structural similarity scan and a coverage overlap report.

npm install -D jscpd
npx jscpd --min-lines 6 --min-tokens 60 --reporters console \
  --pattern "**/*.test.ts" --pattern "**/*.spec.ts"
# Found 34 clones in 21 files
# per-test coverage, so overlap between tests is visible rather than inferred
npx vitest run --coverage --coverage.reporter=json

Implementation

Step 1 — Delete exact and near-duplicates, keeping the better-named one. Structural clones with the same inputs and assertions add no information; the only question is which name describes the behaviour more clearly.

// two tests, one behaviour — keep the second
test('works', () => {
  expect(slugify('Hello World')).toBe('hello-world');
});

test('lowercases and hyphenates a title', () => {
  expect(slugify('Hello World')).toBe('hello-world');
});

Step 2 — Delete tautologies. A test that computes its expectation with the same expression as the implementation passes for every implementation, correct or not.

// tautology: mirrors the implementation, so it can never fail meaningfully
test('calculates the total', () => {
  const expected = items.reduce((n, i) => n + i.price * i.qty, 0);
  expect(cartTotal(items)).toBe(expected);
});

// keep instead: a stated value a reviewer can check by hand
test('totals two line items including quantity', () => {
  expect(cartTotal([{ price: 3, qty: 2 }, { price: 4.5, qty: 1 }])).toBe(10.5);
});

Step 3 — Collapse duplicated coverage down a tier. When a browser test and a unit test verify the same rule, keep the cheap one and let the expensive one verify only what it alone can: that the rule is wired into the interface.

// e2e — was asserting the discount arithmetic through the UI
test('shows a 10% discount over £100', async ({ page }) => {
  await page.goto('/cart?total=150');
  await expect(page.getByTestId('discount')).toHaveText('£15.00');
});

// keep the arithmetic in a unit test; keep the e2e test only for the wiring
test('renders whatever discount the pricing rules return', async ({ page }) => {
  await page.goto('/cart?total=150');
  await expect(page.getByTestId('discount')).toBeVisible();
});

Step 4 — Retire quarantined tests that miss their deadline. A test nobody will fix is not protecting anything; it is consuming attention and eroding trust in the suite. Deleting it is more honest than leaving it, as quarantining flaky tests in CI argues in detail.

# quarantined over 90 days with no commits touching the file
git log --since="90 days ago" --name-only --format= -- e2e/specs/legacy-checkout.spec.ts | sort -u
# (empty — nobody has touched it; retire it)

Step 5 — Delete in a change of its own, with the reason in the message. A deletion buried in a feature branch is invisible to review; one on its own is a two-minute conversation.

git checkout -b prune/duplicate-slug-tests
git rm src/utils/slugify.legacy.test.ts
git commit -m "Remove duplicate slugify tests

Identical inputs and assertions to slugify.test.ts:14. No behaviour is
left unverified: mutation score for slugify.ts is unchanged at 96%."
The four questions that decide whether a test stays A test stays if it can fail for a reason no other test covers, if its failure would name a real defect, if it is at the cheapest tier that can verify the behaviour, and if someone would fix it when it breaks. Keep it only if all four are yes Can it fail for a reason no other test would catch? Would its failure name a defect a user would notice? Is it at the cheapest tier that can verify this? Would someone actually fix it when it breaks?
Four questions, and a single no is enough — the last one disqualifies more tests than people expect.

Step 6 — Prune on a cadence rather than in a campaign. A pruning sprint removes a large batch once and then nothing for two years, by which time the suite has regrown. A standing rule — whoever touches a test file checks its neighbours against the criteria — keeps the suite roughly flat with no scheduled effort at all. The habit matters more than the batch, because the accumulation is continuous.

Verification

Line coverage is the wrong verification, because a duplicate deletion leaves it unchanged and a tautology deletion may lower it while removing nothing of value. Verify with mutation score on the affected file instead: if it is unchanged, the deleted tests were detecting nothing the survivors do not.

npx stryker run --mutate "src/utils/slugify.ts"
# before deletion: 96.15%
# after deletion:  96.15%   ← nothing was protecting anything unique

Then verify the behavioural claim directly by breaking the code. Introduce the defect the deleted test supposedly guarded against and confirm something still fails.

sed -i "s/toLowerCase()/toUpperCase()/" src/utils/slugify.ts
npx vitest run src/utils
# FAIL  slugify.test.ts > lowercases and hyphenates a title
git checkout src/utils/slugify.ts

Finally, watch the consequences for a period rather than declaring victory at merge. A note in the pull request saying which behaviours were relied upon elsewhere, and a look at escaped defects over the following month, is the honest verification — and in practice pruning almost never shows up in that data, which is itself useful evidence for the next round.

Why mutation score verifies a deletion and line coverage does not Deleting a duplicate leaves line coverage unchanged and proves nothing, while an unchanged mutation score shows the remaining tests still detect every change the deleted ones would have caught. line coverage unchanged after deleting a duplicate — and also after deleting something vital mutation score unchanged only when the survivors still detect every change the deleted ones did
Coverage cannot distinguish a redundant test from an essential one; detection can.

Troubleshooting

Symptom: deleting a test drops the mutation score. Diagnosis: it was not redundant — it was the only thing detecting some change. Fix: keep it, or replace it with a cheaper test at a lower tier that kills the same mutants, then re-check. This is the check working as intended.

Symptom: the team resists any deletion. Diagnosis: the decision is being made case by case, so each one is a personal risk. Fix: agree the criteria first, in writing, and then apply them; the conversation changes from “should we delete this” to “does this meet the criteria”, which is a far easier discussion to have.

Symptom: the same duplicates reappear months later. Diagnosis: nothing prevents them, and each author is unaware of the neighbour. Fix: run the clone detector in CI as a warning, and keep tests for one behaviour in one file so a duplicate is visible to whoever writes it.

FAQ

Is it ever right to delete a test without a replacement?

Yes — that is the normal case for a duplicate, a tautology, or a check that a cheaper tier already covers. What deserves scrutiny is deleting a test that is the only thing verifying a behaviour, which the mutation check makes visible. If the score drops, you are not pruning, you are reducing coverage, and that needs a different conversation.

What about tests for code that no longer exists?

Those are not a judgement call at all; they are dead weight that the compiler often will not catch if the test mocks its way around the missing module. Delete them as soon as they are found, and treat their existence as a signal that the earlier removal was incomplete.

Should old regression tests be kept forever?

Not automatically. A regression test earns its place while the defect class is plausible; once the code has been rewritten and the mechanism no longer exists, the test is verifying a hypothetical. Read it against the current implementation, and if nobody can describe how the original bug could recur, it has done its job.

How much should we expect to remove?

Teams that do this seriously for the first time commonly remove between ten and twenty per cent of their tests without any loss in detection. That figure should fall sharply in later rounds; if it does not, the suite is accumulating redundancy faster than it should, which points at review habits rather than at pruning.