A test suite that takes twelve minutes and fails because a button’s CSS class changed is not a safety net. It is a tax. Teams respond by skipping CI or by deleting tests. Then the payment bug ships.
Good tests are cheap to run, fail on behavior you care about, and do not duplicate the implementation. Bad tests lock in accidents and mock so much that they prove the mock works.
This is how to choose tests as an application developer who wants to ship, not as someone collecting coverage percentages.
What a test is for
A test is a stake in the ground: this behavior should stay true. If the behavior is “the function adds two numbers,” a unit test is enough. If the behavior is “a user with a valid card sees an order number,” you need something that touches more layers, or a contract plus a few integration tests.
Coverage tools count lines. They do not count risk. 90% coverage of getters and 0% of refunds is how dashboards look green.
Unit tests that earn their keep
Pure functions, parsers, price calculators, permission checks with explicit inputs: unit test them. Table-driven cases for the weird inputs: rounding, time zones, empty lists, unicode.
Do not unit test a React component by asserting the exact className string from a CSS module. Assert that the submit button is disabled while loading, or that an error message appears. Implementation details will change. The user-facing behavior should not, or the test should change with the product on purpose.
If the unit test needs 40 lines of mocks to construct a service, you are not testing a unit. You are testing your ability to mimic the world. Consider an integration test with a real test database, or a simpler function.
Integration tests
API handler plus database. Worker plus queue (or a fake queue that still serializes). These catch SQL mistakes, transaction mistakes, and status codes. They cost more than unit tests. Keep them for the money paths: signup, login, checkout, permission boundaries.
Use a real Postgres in CI if you use Postgres. SQLite as a stand-in will miss JSON operators and lock behavior. Docker Compose in CI is normal now.
Reset data between tests. Leaking rows is how “works on my machine” infects CI.
End-to-end tests
Playwright or Cypress against a running app: few of these. Login, a happy path, one sad path. They are slow and flaky if they depend on animations, time, or third parties.
Do not E2E every combination. Mock the payment provider with a test mode. Stub email. If the test needs a real SMS, it will flake.
If E2E is flaky, fix the flake or delete the test. A flaky test is worse than no test because people ignore the suite.
What to skip
- Tests that assert snapshots of entire pages and break on spacing
- Tests for third-party library internals
- Tests that duplicate TypeScript by asserting types at runtime in a silly way
- Tests written only to raise coverage on trivial code
- Tests that sleep for 5 seconds “to be safe”
- Tests that depend on production APIs
Skip until it hurts. If a module has broken three times, it has earned tests. If it has never broken and is a thin wrapper, maybe not.
Mocks
Mock at the edge: HTTP to a vendor, clock, random ids if you must. Do not mock your own database layer and then claim you tested the handler. You tested the mock.
If you mock fetch, assert the URL and method, not only that you returned JSON. Otherwise the test passes while the client calls the wrong path.
Time, randomness, and files
Inject a clock. Date.now() in the middle of a domain function is how tests freeze. Seed random, or pass ids in.
For files, use a temp directory. Do not write to the repo.
The pyramid you can actually follow
Many cheap unit tests for logic. Fewer integration tests for persistence and HTTP. A handful of E2E for the journeys that make money. That pyramid is old and still right. Inverting it (mostly E2E) is how CI becomes a weather report.
When a bug ships
First, write a test that fails on the bug. Then fix. That is the only coverage number that matters for that bug. If you cannot write the test, the design is hard to observe. Improve observability or seam, then test.
Review questions
- Does this fail if the behavior breaks, and pass if we refactor internals?
- Is it faster than a second?
- Does it need the network?
- Will it flake on CI load?
If the answers are no, no, yes, yes, rewrite it.
Contract tests between frontend and API
If the OpenAPI drifts, Pact or a simpler “fixture JSON must match a schema” test catches it. You do not need a full consumer-driven contract religion. You need the list endpoint’s envelope to fail CI when someone removes nextCursor.
MSW for frontend tests is a mock. Pair it with at least one integration test against a real server for the money path so the mock cannot drift forever.
Flakes from time and animation
waitFor with a generous timeout hides a slow query until CI is loaded. Prefer deterministic: fake timers for debounce, disable animation in test env, wait for a role and name not a CSS class. If a test needs waitFor more than once, it is probably E2E pretending to be unit.
Snapshot tests with a budget
A snapshot of a pure formatter’s output can be great. A snapshot of a whole page with dates and random ids will fail daily. If you use snapshots, keep them small, review the diff like a PR, and never -u the whole suite without reading.
Visual regression (Percy, Chromatic) is a different budget. Use it for a design system, not for every marketing experiment unless you like noise.
Test data builders
makeUser({ role: 'admin' }) beats a 30-line fixture copied four times. When the User shape changes, you change the builder. Builders are how integration tests stay alive. Copy-paste fixtures are how they die.
Testing errors on purpose
A test that only hits 200 is half a test. Force a 422 and assert the field error. Force a 401 and assert redirect. If the suite has no failed-login test, you do not know whether you leak “user exists.” Add the sad path for auth and payments first.
Parallel tests and shared DB
If tests share one database and run in parallel, they will flake on unique emails. Use transactions that roll back, separate schemas, or serial tests for that file. Flakes that “only happen in CI” are often this. Fix the isolation. Do not retry the job until it goes green and call it done.
Coverage gates
A hard 90% gate encourages junk tests. A gate on the money directories, or no gate plus review, is often healthier. If you have a gate, exclude generated files. Do not let coverage theater block a security fix.
Artikals is for engineers who are tired of both “we don’t test” and “we test everything with Selenium.” Tests are a budget. Spend it on refunds, authz, and parsers. Skip the CSS class names. Sleep better.