How to Write Unit Tests Worth Maintaining
A test suite is an asset or a liability depending on one property: whether it fails when the code is wrong, and only then. Tests that break every time you refactor working code are a tax, and teams eventually respond by deleting or ignoring them.
The usual root cause is testing implementation rather than behaviour — asserting that a specific function was called with specific arguments, rather than that the right thing happened. Those tests are coupled to how the code works today, so any refactor breaks them even when behaviour is identical.
Test behaviour at the boundary you'd defend
Write tests against the contract a caller depends on. If a change is invisible to callers, it should be invisible to tests.
- ›Assert on outputs and observable side effects, not on which internal functions were called.
- ›Mock things you don't own and can't run: payment providers, email APIs, the clock. Don't mock your own modules.
- ›Use a real database in tests. Containers make this easy, and mocked database calls test your mock, not your query.
- ›Coverage is a diagnostic, not a target. It shows which lines ran, not whether the assertions were meaningful.
- ›One integration test through the real stack catches more than twenty unit tests of individually mocked pieces.
The approaches
Unit tests for logic
// best for: Pure functions and business rules: pricing, permissions, validation, state machines.
- JavaScript
- TypeScript
- Python
- Go
- Java
- Ruby
- PHP
- Rust
Unit tests earn their keep on code with many branches and no I/O. A pricing function with volume discounts, tax, and promotional rules has dozens of meaningful cases, they run in microseconds, and each failure points at exactly one thing.
The structural advice that pays off most is separating decisions from effects. A function that calculates what should happen is trivially testable; a function that calculates and then writes to the database and sends an email requires mocking to test at all. Pushing the I/O to the edges gives you a large, easily-tested core.
When a test needs elaborate setup and four mocks, that's usually the design telling you the unit has too many dependencies. The instinct to add more mocking machinery is generally the wrong response.
// Brittle: asserts HOW it worked. Any refactor breaks this
// even when behaviour is unchanged.
expect(repo.findById).toHaveBeenCalledWith(42);
expect(mailer.send).toHaveBeenCalledTimes(1);
// Durable: asserts WHAT happened. Survives refactors,
// fails when behaviour is actually wrong.
const result = await placeOrder({ userId: 42, items });
expect(result.status).toBe("confirmed");
expect(await db.orders.count({ userId: 42 })).toBe(1);The gotcha
Tests that assert on mock call counts and arguments fail on every refactor of working code. Teams respond by updating the assertions mechanically, which means the tests no longer verify anything — they just mirror the implementation. Assert on results.
Integration tests against real dependencies
// best for: Anything touching a database, which is most of what a backend does.
- Docker
- PostgreSQL
- Django
- Ruby on Rails
- Laravel
- NestJS
- GitHub Actions
Testcontainers and Docker Compose make a real Postgres in CI straightforward, and it changes what your tests prove. A mocked query object verifies your mock's behaviour; a real database verifies the SQL, the constraints, the migrations, and the transaction semantics.
It catches an entire class of bug unit tests structurally cannot: a missing migration, a unique constraint violation under concurrency, a cascade delete that removes more than intended.
Isolate tests with a transaction rolled back after each one. That's faster than truncating tables and gives every test a clean database without ordering dependencies.
These are slower — seconds rather than microseconds — which is the right trade for the paths that matter. A handful covering critical flows beats a large suite of mocked units.
The gotcha
Tests that depend on execution order because they share database state. They pass locally, then fail in CI when the runner parallelizes or shuffles. Each test must set up everything it needs and leave nothing behind — a per-test transaction rollback is the simplest way to guarantee it.
End-to-end tests (Playwright, Cypress)
// best for: A small number of critical user journeys: signup, checkout, the core workflow.
- React
- Next.js
- Vue.js
- Svelte
- Angular
- Django
- Ruby on Rails
End-to-end tests drive a real browser against a running application and are the only tests that verify the whole thing actually works together. They're also the slowest and the most prone to flakiness, so the right number is small and deliberately chosen — the flows where a silent break costs you money.
Playwright has largely won here on auto-waiting, which removes the single biggest source of flakiness. It waits for elements to be actionable rather than requiring explicit sleeps, and arbitrary `waitForTimeout` calls are the classic source of tests that pass locally and fail on slower CI machines.
Select elements by role and accessible name, or by explicit test IDs. CSS class selectors couple your tests to styling, so a Tailwind refactor breaks the suite for no behavioural reason.
The gotcha
Fixed timeouts (`waitForTimeout(2000)`) make tests both slow and flaky — long enough to waste time on every run, never long enough for a loaded CI machine. Wait for the condition (an element visible, a request settled), never for a duration.
Common pitfalls
Chasing a coverage percentage
Coverage measures which lines executed, not whether anything was verified. A test calling a function with no assertions produces full coverage and zero value. Use coverage to find untested areas, never as a target — targets produce tests written to satisfy the metric.
Mocking your own code
Mocking internal modules means testing that your mocks agree with each other. When the real module changes, the mock doesn't, and the tests keep passing against a fiction. Mock only what you don't control.
Tests that depend on the current time
`new Date()` in code under test produces failures at month boundaries, across timezones, and on daylight-saving days. Inject the clock or freeze it, so the test is deterministic.
Ignoring flaky tests
A test failing 5% of the time trains the team to re-run rather than investigate, and after that a real failure gets re-run too. Quarantine it the day it's noticed and fix it as its own task.
Questions people actually ask
How much coverage should I aim for?
No number. Cover the code where a bug is expensive — money, permissions, data integrity — thoroughly, and don't write tests for getters to hit a threshold. A 60% suite with meaningful assertions beats 95% of coverage theater.
Should I mock the database?
No. Run a real one in a container. Mocked database calls verify your mock rather than your query, and they cannot catch a missing migration, a constraint violation, or a transaction bug.
What's the right mix of test types?
Many fast unit tests over pure logic, a solid layer of integration tests over the paths that touch the database, and a handful of end-to-end tests over critical journeys. The exact ratio matters less than having all three.