← All posts

A Pragmatic Approach to Testing

What to test, what not to test, and why 100% coverage is a vanity metric

Every project eventually produces the same argument: how much test coverage is enough? Someone always brings up 100% as the goal, and I always push back. Not because testing doesn't matter — it does, a lot — but because coverage as a metric optimizes for the wrong thing.

Here's the mental model I've settled into after years of building client software: tests should protect you from the failures that actually hurt. Everything else is optional.

The cost of testing is real

This sounds obvious, but it gets ignored constantly. Tests take time to write. They take time to maintain. A badly written test that tests the wrong thing will give you false confidence and then fail on a refactor that didn't break anything meaningful. A well-written test suite that covers the wrong surface area will let serious bugs through while requiring constant upkeep.

The question isn't "do we have tests?" It's "are the tests we have earning their keep?"

What actually deserves test coverage

There are two categories of code where I don't compromise on testing: business logic and data integrity.

Business logic is any code that makes decisions your users or your business depends on. Pricing calculations, access control, subscription state, quota enforcement, billing events. These are places where a bug costs you real money or real trust, and where the logic is often complex enough that reading the code doesn't make the correctness obvious.

For a SaaS product, access control is the clearest example. If I'm building a feature that gates certain behavior behind a subscription tier, I want explicit tests for what a free user can and can't do, what a paid user gets, what happens when a subscription lapses. Not because I don't trust myself to write it correctly, but because this is exactly the kind of logic that gets quietly broken six months later when someone adds a feature nearby and doesn't realize they've touched the boundary.

Data integrity is the other non-negotiable. Any code that writes to your database — especially mutations with side effects, cascading updates, or anything that involves financial transactions — deserves integration coverage. Not mocks. Real queries against a test database. Mocking your data layer is the testing equivalent of testing whether your packing tape dispenser works without checking if the tape is actually sticky.

Where I don't bother

Pure UI rendering. I don't write tests to verify that a component renders the right classnames, or that a button exists, or that a page shows the right heading. That's the job of visual review and manual QA. Snapshot tests that encode the current output of a component tell you something changed — they don't tell you whether the change was good or bad, and they generate maintenance burden on every intentional update.

Similarly, I don't test framework behavior. If I'm using Next.js routing, I don't write tests that verify Next.js routes work. I trust the framework. If Prisma generates a query, I don't test that Prisma generated the query I expected. These tests are maintenance work with no signal value.

One-off utility functions are often not worth testing either — unless they're doing something clever. A function that formats a date doesn't need a test. A function that determines whether a subscription is still valid for a specific transfer type, given a preview flag and a current quota state, absolutely does.

The 100% coverage problem

Coverage tools measure what lines of code were executed during a test run. They do not measure whether the tests actually caught a bug, whether the assertions are meaningful, or whether the edge cases that matter were exercised.

A function that takes two parameters and has one test will show 100% line coverage if the test exercises both branches. But if the test asserts expect(result).toBeTruthy() instead of checking the actual value, the test is worthless. It will pass even if your function is completely wrong, as long as it returns something non-null.

I've seen codebases with 95% coverage and critical bugs in production that the test suite couldn't catch because the tests were asserting the wrong things. I've also seen codebases with 40% coverage that barely had bugs, because the coverage was concentrated exactly where the risk was.

Coverage is a useful signal for identifying completely untested areas. It's a terrible signal for confidence.

The right way to think about it

Instead of asking "what's our coverage percentage," I ask three questions:

What are the highest-consequence failure modes? If the audio proxy endpoint serves the wrong file to the wrong user, that's a data leak — it deserves a test. If a button is misaligned by 2px, that's a visual issue — review it in the browser.

Where is the logic complex enough that reading the code doesn't obviously confirm correctness? The simpler a function, the less likely a test will find anything that reading it wouldn't. The more conditional branches, state dependencies, and edge cases, the more a test earns its keep.

What's the failure blast radius? A bug in metering logic that lets a user exceed their quota silently is a billing problem. A bug in the waveform player that misrenders a scrubber is a display problem. Both are bugs, but they have very different implications.

Integration over unit for complex systems

For client projects — full-stack SaaS with authentication, subscriptions, file handling — I weight integration tests over unit tests. Unit tests are fast and isolated, but they don't tell you whether the pieces work together. Integration tests are slower, but they exercise the actual path: does this API route correctly validate the session, check the subscription state, apply the quota, write the right record, and return the right response?

The main objection is speed. A test suite that hits a database takes longer to run. That's true. It's also usually fast enough to run in CI, and it actually catches the bugs that matter. A mock-heavy unit test suite that runs in 3 seconds and lets real integration bugs through is a bad trade.

Playwright for the critical user paths

For web apps, I don't try to unit test the full UI. But I do write end-to-end tests for the critical paths — the flows where a bug has immediate user-visible impact and the steps are hard to exercise manually on every deploy. User authentication, subscription gating, anything involving a payment. These tests are expensive to write and maintain, but there's no substitute for actually clicking through the flow and asserting the right thing happened.

The rest of the UI I review manually. It's faster, it catches things automated tests don't, and it keeps me honest about what the product actually looks like.

The honest answer

There's no right coverage number. There's no framework that tells you exactly what to test. What there is: an honest accounting of where the risk is, and a test suite that's concentrated there.

If someone asks me what percentage of my code has test coverage, I'll tell them. But what I actually care about is whether the logic that handles money, access, and data integrity has solid, meaningful test coverage. That's the number that matters.