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.
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%."
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.
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.
Related
- Back to Cost-Benefit Analysis of Test Layers
- Measuring the running cost of a test suite — what each kept test costs per year.
- Finding weak assertions with mutation scores — the measure that makes deletion safe.
- When to skip integration tests in favor of unit tests — deciding the right tier before writing at all.