Our team is four engineers building a B2B invoicing product. There is no QA department, releases go out several times a week, and for a long time our test suite was a mix of whatever each person felt like writing that day: 900 unit tests that mocked the database, two Selenium scripts nobody trusted, and no tests at all for the PDF generator that customers actually complained about. Coverage was 71 percent. The bugs that reached customers were almost never in the 71 percent.

This guide is the strategy we moved to over about two quarters. It is not a pyramid diagram. It is a set of decisions about where a small team should spend a limited testing budget, with concrete examples you can copy. By the end you should be able to rank your own features by risk, pick the right kind of test for each, and keep the suite fast enough that people still run it.

Start from risk, not from coverage

Coverage tells you which lines executed, not which failures you would notice. We replaced the coverage target with a one-page risk register. For each feature we scored two things from 1 to 3:

  • Impact if it breaks: money, data loss, legal exposure, or a blocked customer workflow.
  • Likelihood of breaking: change frequency, complexity, external dependencies, past incidents.
Feature Impact Likelihood Score Test investment
Invoice totals and tax 3 3 9 Exhaustive unit tests plus integration
Payment webhook handling 3 2 6 Integration and contract tests
PDF rendering 2 3 6 Snapshot of extracted text plus one E2E
CSV customer import 2 2 4 Integration with real sample files
Dashboard charts 1 2 2 Smoke render only
Settings page copy 1 1 1 Nothing automated

The rule is blunt: anything scoring 6 or more gets tests before it ships, anything at 2 or below gets none unless it breaks twice. We review the register at the start of each quarter and whenever an incident happens. The incident review always asks one question: was this feature scored too low?

Unit tests where the logic is dense

Pure logic with many branches is where unit tests pay off, because they are cheap and you can enumerate cases. Tax and rounding rules were our densest area, so we used table-driven tests that read like a specification:

import { describe, it, expect } from 'vitest';
import { invoiceTotal } from '../src/billing/totals';

const cases = [
  { name: 'single line, 10% tax', lines: [{ qty: 1, unit: 1999 }], taxBp: 1000, want: 2199 },
  { name: 'rounding half up per invoice', lines: [{ qty: 3, unit: 333 }], taxBp: 750, want: 1074 },
  { name: 'zero quantity ignored', lines: [{ qty: 0, unit: 5000 }], taxBp: 1000, want: 0 },
  { name: 'credit line reduces total', lines: [{ qty: 1, unit: 5000 }, { qty: -1, unit: 1500 }], taxBp: 0, want: 3500 },
];

describe('invoiceTotal', () => {
  it.each(cases)('$name', ({ lines, taxBp, want }) => {
    expect(invoiceTotal(lines, taxBp)).toBe(want);
  });
});

Amounts are integer minor units and tax is in basis points, so every expected value is exact. When product changes a rule, they review the table, not the code.

We stopped writing unit tests for controllers, repositories and anything whose main job is to talk to another system. Those tests mostly verified that our mocks returned what we told them to.

Integration tests over mocks for anything touching storage

Most of our real bugs lived at boundaries: a migration that changed a default, a query that worked on SQLite but not on PostgreSQL, a unique constraint we forgot. Mocked repository tests cannot see any of that. We now run integration tests against a real PostgreSQL in a throwaway container using Testcontainers:

import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { beforeAll, afterAll, it, expect } from 'vitest';
import { createDb, migrate } from '../src/db';
import { importCustomers } from '../src/import/customers';

let pg: StartedPostgreSqlContainer;
let db: ReturnType<typeof createDb>;

beforeAll(async () => {
  pg = await new PostgreSqlContainer('postgres:16-alpine').start();
  db = createDb(pg.getConnectionUri());
  await migrate(db);
}, 60_000);

afterAll(async () => {
  await db.close();
  await pg.stop();
});

it('rejects duplicate customer emails case-insensitively', async () => {
  const result = await importCustomers(db, 'fixtures/customers-duplicate-case.csv');
  expect(result.inserted).toBe(1);
  expect(result.errors).toEqual([{ line: 3, reason: 'duplicate email' }]);
});

One container per test file, migrations applied once, each test wrapped in a transaction that rolls back. The whole integration suite (about 180 tests) runs in a little over two minutes on CI. That is slower than mocks, and worth it: in the first month it caught a case-sensitive unique index and a timezone-naive timestamp column that had both been in production for a year.

Contract tests at the edges you do not control

We integrate with a payment provider, an accounting export and our own mobile app. End-to-end tests across those systems were slow and flaky, so we test the agreement instead:

  • Inbound webhooks: we store real, anonymised webhook payloads from the provider's test mode as fixtures and run them through the full handler, including signature verification with a test secret. When the provider changes a payload shape, we capture a new fixture and the diff shows exactly what moved.
  • Our API consumed by the mobile app: consumer-driven contracts with Pact. The mobile team publishes the requests and responses they rely on; our CI verifies the API still satisfies them before deploy. This replaced a recurring class of bug where we renamed a field the app still read.
# provider verification step in CI (excerpt)
npx vitest run test/contracts/provider.verify.test.ts

Contract tests answer "did we break someone else" in seconds, which is the question a small team cannot afford to answer by hand.

A few end-to-end happy paths, not a second test suite

We keep exactly five Playwright tests, one per money-or-access journey: sign up, create and send an invoice, customer pays an invoice, download the PDF, invite a teammate. They run against a production-like staging environment after each deploy, not on every commit.

import { test, expect } from '@playwright/test';

test('customer can pay a sent invoice', async ({ page }) => {
  await page.goto('/i/test-invoice-token');
  await expect(page.getByRole('heading', { name: 'Invoice INV-0042' })).toBeVisible();
  await page.getByRole('button', { name: 'Pay now' }).click();
  await expect(page.getByText('Payment received')).toBeVisible({ timeout: 15_000 });
});

The rules that keep them trustworthy: role-based locators instead of CSS selectors, no fixed sleeps, each test creates its own data through the API, and a test that flakes twice in a week is either fixed that week or deleted. A flaky E2E test teaches the team to ignore red builds, which is worse than having no test.

Test data that does not rot

Shared fixture files that every test edits are where suites go to die. We use small builders with safe defaults, and override only what the test is about:

export function anInvoice(overrides: Partial<Invoice> = {}): Invoice {
  return {
    id: crypto.randomUUID(),
    number: 'INV-0001',
    currency: 'EUR',
    customerEmail: 'billing@example.com',
    lines: [{ qty: 1, unit: 10_000 }],
    status: 'draft',
    ...overrides,
  };
}

// usage: the test says only what matters
const overdue = anInvoice({ status: 'sent', dueDate: '2026-01-01' });

For integration tests we keep anonymised CSV and PDF samples from real support tickets in fixtures/, named after the ticket that produced them. Every bug fix adds its triggering input there, which turns the fixtures folder into a record of what has actually gone wrong.

Keeping the suite fast enough to run

A suite nobody runs locally is a suite that only catches bugs after push. Our budgets:

Layer Count Time budget When it runs
Unit about 600 under 20 s Pre-commit and every CI run
Integration about 180 under 3 min Every CI run
Contract about 40 under 30 s Every CI run, blocks deploy
E2E 5 under 4 min After deploy to staging

When a layer exceeds its budget, we treat it like a production performance regression and fix it in the same sprint.

Common mistakes

Mistake Why it hurts Do this instead
Chasing a coverage percentage Rewards testing easy code, not risky code Rank features by impact and likelihood
Mocking the database in repository tests Misses constraints, types and query bugs Use a real database in a container
Dozens of UI end-to-end tests Slow, flaky, ignored Five journeys, everything else lower down
One giant shared fixture file Every change breaks unrelated tests Builders with defaults plus per-bug samples
Keeping flaky tests "for now" Teaches everyone to ignore red Fix within a week or delete

Key takeaways

  • Decide what to test from a short risk register, reviewed after every incident.
  • Use unit tests for dense pure logic and table-driven cases for business rules.
  • Test anything that touches storage against the real database engine.
  • Protect integrations with contract tests and captured payload fixtures instead of cross-system E2E.
  • Keep a handful of E2E tests for money and access journeys, and never tolerate flakiness.
  • Give every test layer a time budget so the suite stays something people run.

References