"Don't ask 'do I have enough tests?' Ask 'is this thing worth a test?' One per case."
The Frame That Actually Works
'Do we have enough tests?' is unanswerable. There's always a regression that would have been caught by ONE more test, somewhere. The frame that works at every meeting and every code review is per-thing: is this thing worth a test? Two factors:
Cost of failure — if this breaks, what does it cost? Your time? A user's time? Money? Trust? Quantify ROUGHLY.
Cost of the test — how long does it take to write? How long does it take to run? How often will you have to update it?
High cost-of-failure + low cost-of-test = obvious yes. Low + high = obvious no. Everything in the middle is judgment — and explicit framing makes the judgment defensible.
The Unit / Integration / E2E Spectrum, Repriced
Each layer has different cost characteristics:
Unit (Vitest) — Cheap to write (~5 minutes), cheap to run (~30 ms), narrow blast radius if it breaks (one function). Best for: pure logic, edge cases, anything you can express as 'input X yields output Y.'
Integration (Vitest + MSW + RTL) — Medium cost (~15 min to write, ~500 ms to run), medium blast radius. Best for: component interactions with mocked APIs, route handlers, multi-step flows.
E2E (Playwright) — Expensive (~30 min to write a stable one, ~5 s to run, brittle to maintain), wide blast radius. Best for: critical user journeys — sign up, checkout, payment, anything where the seams between layers being broken would cost real money or trust.
The 'Don't Test This' Cases
Equally important: knowing what NOT to test. Common candidates:
Framework code. React rendered the prop you passed it; this is a React test, not yours.
Trivial getters / setters. The test is longer than the code.
Pure data structures. A const array doesn't need a test.
Cosmetic styling. If the test would need updating every time a designer tweaks padding, the test is testing implementation, not behavior.
Code that's actively being deleted. No point investing in tests for the dying.
'Should I test this?' has the same answer as 'should I document this?' — usually no, occasionally yes, and the criteria are similar. Both serve future-you. Both have ongoing maintenance cost. Both fail when written for the wrong reason ('we should have docs' / 'we should have tests' is the death sentence for both).
The 'When to Add E2E' Trigger
Most projects don't need E2E on day one. Add an E2E test when one of these triggers fires:
A bug reached production that ONLY a real-browser test would have caught.
A critical flow (auth, checkout) gets refactored — write the E2E first so you can refactor without holding your breath.
Multiple teams ship to the same surface and integration regressions hit weekly.
The product has paying customers and the regression cost just became real money.
If none of those is true, your test budget is better spent on unit + integration. E2E added preemptively becomes maintenance debt that nobody runs.
The Test Budget as a Resource
You have a finite amount of attention to spend on testing. The budget includes: writing time, maintenance time, CI minutes, mental load when reading failures. Sizing the suite to the budget — instead of letting the suite grow until the budget bursts — is the senior-engineer move. The pyramid (or trophy, or whatever) is a TOOL for thinking about budget allocation. It's not a prescription.
Code
The decision matrix you'll actually use·text
# A practical test-budget decision matrix
│ Low cost-of-failure │ High cost-of-failure
──────────────────────────────┼───────────────────────┼─────────────────────────
Low cost-of-test │ Worth it (cheap) │ DEFINITELY worth it
(unit, pure function) │ e.g. format helpers │ e.g. money math
──────────────────────────────┼───────────────────────┼─────────────────────────
Medium cost-of-test │ Skip unless │ Worth it
(integration w/ providers) │ the test is fun │ e.g. auth, payment
──────────────────────────────┼───────────────────────┼─────────────────────────
High cost-of-test │ Definitely skip │ Worth it for the
(E2E, flaky, slow) │ │ critical paths only
│ │ e.g. checkout, signup
Cheap unit test — pure logic·typescript
// Unit-level test — cheap, fast, narrow blast radius (5 min to write)
import { describe, it, expect } from 'vitest';
import { calculateTax } from './tax';
describe('calculateTax', () => {
it('applies the standard rate', () => {
expect(calculateTax(100, 'US-CA')).toBeCloseTo(8.75);
});
it('exempts food in NY', () => {
expect(calculateTax(100, 'US-NY', { category: 'food' })).toBe(0);
});
});
Integration test — component + mocked API·typescript
// Integration test — moderate cost, covers a flow (~15 min to write)
import { describe, it, expect } from 'vitest';
import { render, screen } from './test-utils';
import userEvent from '@testing-library/user-event';
import { CheckoutForm } from './checkout-form';
describe('<CheckoutForm />', () => {
it('submits with the right payload when the user fills it out', async () => {
const user = userEvent.setup();
render(<CheckoutForm />);
await user.type(screen.getByLabelText('Card number'), '4242424242424242');
await user.type(screen.getByLabelText('Expiry'), '12/30');
await user.click(screen.getByRole('button', { name: 'Pay' }));
expect(
await screen.findByRole('alert', { name: /payment processing/i })
).toBeInTheDocument();
});
});
E2E test — the critical journey, end-to-end·typescript
// E2E test — expensive, covers a critical journey end-to-end (~30 min stable)
import { test, expect } from '@playwright/test';
test('user can sign up, log in, and reach the dashboard', async ({ page }) => {
// Sign up
await page.goto('/signup');
await page.getByLabel('Email').fill(`pippa+${Date.now()}@example.com`);
await page.getByLabel('Password').fill('s3cret!');
await page.getByRole('button', { name: 'Create account' }).click();
// Land on dashboard automatically after signup
await expect(
page.getByRole('heading', { name: /welcome back/i })
).toBeVisible();
// Sign out and sign back in — confirms the round trip
await page.getByRole('button', { name: 'Sign out' }).click();
await page.getByLabel('Email').fill(`pippa+${Date.now()}@example.com`);
await page.getByLabel('Password').fill('s3cret!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(
page.getByRole('heading', { name: /welcome back/i })
).toBeVisible();
});
Look at your current test suite (or list mentally if it's small). For each test, ask: what's the cost-of-failure if I deleted this test, and what's the cost-of-maintaining it? Identify (a) one test that's clearly worth keeping, (b) one test that's borderline, (c) one test you suspect is overhead with no real signal. Don't delete anything yet — just see them with the budget frame.
Hint
The 'overhead' tests are usually the ones nobody can explain when asked 'what bug would this catch?' If the answer is 'I'm not sure, maybe React stops rendering?' — that test is testing the framework, not your code. Mark it for retirement.
Progress
Progress is local-only — sign in to sync across devices.