Skip to content
lazy devs
4 min readLazy Devs

A testing strategy that actually pays off

Most test suites are either too thin to catch bugs or so bloated they slow you down. Here is a layered strategy that finds real bugs without turning CI into a tax.

Most teams we work with have one of two testing problems. Either they have almost no tests and ship with their fingers crossed, or they have 4,000 tests, a 12 minute CI run, and they still get paged at 2am. Both teams are paying for testing without getting the thing they actually want, which is the freedom to change code without fear.

This is a strategy that pays off. Not "more coverage." Not "100% green." A small set of tests that catch the bugs that would actually hurt you, run fast enough that nobody is tempted to skip them, and stay green for the right reasons.

Stop counting coverage, start counting confidence

Coverage percentage is the most overrated metric in testing. A line being executed during a test run tells you nothing about whether you asserted anything useful about it. You can hit 90% coverage with tests that would pass even if the function returned garbage.

The better question is: when this test fails, do you learn something true about your system? If a test breaks every time you rename a variable or restructure a component, it is testing your implementation, not your behavior. Those tests are a liability. They make refactoring expensive, which is exactly the thing testing was supposed to make cheap.

So the goal is not a number. The goal is a suite where a red build means "something a user cares about is broken" and a green build means "you can ship."

The layers that earn their keep

Think in three layers, and spend your time where the bugs actually live.

Unit tests for logic that branches

Pure functions with real branching logic are the cheapest, most valuable tests you will write. Pricing rules, date math, permission checks, parsers, anything where the output depends on tricky input. No mocks, no setup, just input and output.

// lib/pricing.ts
export function applyDiscount(
  cents: number,
  code: "WELCOME10" | "HALFOFF" | null,
): number {
  if (cents <= 0) throw new Error("price must be positive");
  switch (code) {
    case "WELCOME10":
      return Math.round(cents * 0.9);
    case "HALFOFF":
      return Math.round(cents * 0.5);
    default:
      return cents;
  }
}
// lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { applyDiscount } from "./pricing";
 
describe("applyDiscount", () => {
  it("rounds the WELCOME10 discount instead of leaving fractional cents", () => {
    // 1999 * 0.9 = 1799.1, which must not become a fractional value
    expect(applyDiscount(1999, "WELCOME10")).toBe(1799);
  });
 
  it("returns the original price when no code is given", () => {
    expect(applyDiscount(1999, null)).toBe(1999);
  });
 
  it("rejects non-positive prices", () => {
    expect(() => applyDiscount(0, "HALFOFF")).toThrow("price must be positive");
  });
});

Notice that last case. The rounding bug and the zero-price guard are the kind of things that ship silently and show up as a confused support ticket three weeks later. That is the test paying for itself.

Integration tests for the seams

This is where most bugs hide and where most teams under-invest. The seams are the places where your code meets something you do not fully control: the database, an API route, an auth boundary. A unit test with a mocked Postgres client proves your mock works. An integration test that hits a real database proves your query works.

For a Next.js route handler, test the actual handler against a real (test) database. Use something like pg-mem or a disposable Postgres container so the test is fast and isolated.

// app/api/orders/route.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { POST } from "./route";
import { resetDb, seedUser } from "@/test/db";
 
beforeEach(async () => {
  await resetDb();
});
 
describe("POST /api/orders", () => {
  it("rejects an order for a product the user does not own access to", async () => {
    const user = await seedUser({ plan: "free" });
 
    const res = await POST(
      new Request("http://test/api/orders", {
        method: "POST",
        headers: { "x-user-id": user.id },
        body: JSON.stringify({ productId: "pro-only-sku", quantity: 1 }),
      }),
    );
 
    expect(res.status).toBe(403);
    const body = await res.json();
    expect(body.error).toMatch(/upgrade/i);
  });
});

This single test exercises the route parsing, the auth check, the database lookup, and the error response. If any of those four things break, you find out here. That is a far better return than four separate mocked unit tests that each assume the other three work.

A few end-to-end tests for the money paths

End-to-end tests are slow and flaky, so you want very few of them, and they should only cover paths where a failure means lost revenue or a locked-out user. Sign up, log in, checkout, and the one core action your product exists to do. If you have more than a dozen of these, you are using the wrong layer for something.

Run them with Playwright against a real build, not a dev server. The dev server has different error handling and looser timing, so a passing dev-server test can hide a production break.

Make tests deterministic or delete them

A flaky test is worse than no test. It trains your team to hit "re-run" without thinking, and the day a real bug shows up as a red build, everyone assumes it is the flake again.

The usual culprits are time, randomness, network, and shared state. Freeze the clock with vi.useFakeTimers(). Seed any random source. Never let a test depend on data left behind by another test, which is why resetDb() ran in beforeEach above. If a test fails once in fifty runs, it is broken. Fix the determinism or remove it.

Where to spend, where to skip

Skip tests for thin glue code. A component that takes props and renders them with no logic does not need a test. A config object does not need a test. A wrapper that forwards a call to a library you trust does not need a test. Testing these adds maintenance with no payoff.

Spend on anything that decides. Money, permissions, data integrity, anything irreversible. A bad render is annoying. A wrong charge or a leaked record is a real problem, and that is exactly where a single sharp test buys you a lot of sleep.

The payoff, concretely

A suite built this way is small, maybe a few hundred tests for a mid-size app, and it runs in under two minutes because the slow end-to-end layer is tiny. When it goes red, it is almost always a real bug, so people trust it. When it is green, people ship without a meeting about it. That trust is the entire return on investment. Everything else is bookkeeping.

The takeaway: do not chase coverage. Build a layered suite that tests behavior at the seams where bugs live, keep it deterministic, and aim every test at code that makes a decision. A test you trust is worth fifty you ignore.

If you want a second pair of eyes on a suite that has stopped paying off, that is the kind of thing we do.

Related service

Web App Development

Production React and Next.js apps, built to last.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com