Start a project

Not everything deserves the same kind of test

A system moving real money doesn’t need every line covered equally — it needs the right things covered rigorously and the rest covered proportionately. A regulated wallet we maintain runs 590 test files at 90%-plus unit coverage, and that number matters less than where the coverage is concentrated: money-movement logic, the identity verification gate, ledger arithmetic, and event handlers that react to financial state changes get the most scrutiny. A formatting helper or an admin-only display label gets a test because it’s easy to write one, not because its absence would be dangerous.

Deciding what deserves rigorous testing starts from one question: if this is wrong, does money move incorrectly, or does a regulatory guarantee break? If yes, it gets unit tests for every branch, an integration test for its real interaction with the database and any collaborating service, and usually a place in the identity-gate CI check if it’s a money-movement command. If no, ordinary test discipline applies and no more.

What a unit test should actually assert in this domain

Money-movement unit tests earn their keep by covering the boundary conditions that are easy to get wrong and expensive when they’re wrong: rounding, currency mismatches, negative or zero amounts, and the exact sequence of state transitions a transfer goes through.

describe("transfer amount validation", () => {
  it("rejects a transfer of zero", () => {
    expect(() => validateTransferAmount(toMoney(0, "USD"))).toThrow(InvalidAmountError);
  });

  it("rejects a negative amount", () => {
    expect(() => validateTransferAmount(toMoney(-500, "USD"))).toThrow(InvalidAmountError);
  });

  it("rejects a currency mismatch between sender and recipient wallets", () => {
    const sender = makeWallet({ currency: "USD" });
    const recipient = makeWallet({ currency: "crypto:USDC" });
    expect(() => validateTransfer(sender, recipient, toMoney(100, "USD")))
      .toThrow(CurrencyMismatchError);
  });

  it("rounds fee calculation to the currency's minor unit, never up on the customer's balance", () => {
    const fee = calculateFee(toMoney(1000, "USD"), FEE_SCHEDULE_STANDARD);
    expect(fee.amount).toBe(29); // $0.29, not rounded to $0.30 against the customer
  });
});

None of these need a database. They’re pure functions tested with plain inputs and outputs, and they run in milliseconds, which is exactly why there’s no excuse for skipping exhaustive coverage of the branches that matter.

What a unit test should not try to assert

Whether a transfer actually persists correctly, whether two commands running concurrently against the same wallet interact safely, whether an event actually reaches its handler — none of that is answerable by a unit test with mocked collaborators. Mocking the database to test “does this code correctly write to the database” tests the mock, not the system. That’s what integration tests are for, and pretending a unit test with a fake repository covers this is worse than no test, because it creates false confidence.

Why integration tests run inside a rolled-back transaction

Integration tests for money-movement logic run against a real database, and each test wraps its actions in a transaction that gets rolled back when the test finishes, rather than committed and cleaned up afterward:

describe("cash-out command", () => {
  let tx: Transaction;

  beforeEach(async () => {
    tx = await db.beginTransaction();
  });

  afterEach(async () => {
    await tx.rollback();
  });

  it("debits the wallet and creates a ledger entry atomically", async () => {
    const wallet = await createTestWallet(tx, { balance: toMoney(5000, "USD") });
    await cashOutCommand.handle({ tx }, { walletId: wallet.id, amount: toMoney(2000, "USD") });

    const updated = await getWallet(tx, wallet.id);
    const ledgerEntry = await getLatestLedgerEntry(tx, wallet.id);

    expect(updated.balance.amount).toBe(3000);
    expect(ledgerEntry.amount).toBe(-2000);
  });
});

This buys three things a fixture-and-cleanup approach doesn’t. Tests are fast, because a rollback is cheaper than deleting rows through application code. Tests are isolated from each other automatically, because nothing a test writes survives past its own rollback, so there’s no shared mutable database state for one test’s leftover data to corrupt another’s assumptions. And tests are safe to run against something close to a production-shaped schema with real constraints and triggers active, because nothing committed ever needs cleaning up — a failed test that would otherwise leave orphaned rows simply leaves nothing at all.

Where this stops being enough

Rolled-back transactions test one command’s interaction with the database correctly, but they don’t, by themselves, test concurrency — two rolled-back transactions in two separate tests don’t observe each other, which means a race condition between two real concurrent requests against the same wallet needs its own dedicated test that actually runs both paths concurrently against a committed, shared state, cleaned up explicitly afterward rather than rolled back.

What this means for you

Match the rigor of a test to the cost of being wrong: exhaustive unit tests for the arithmetic and validation that money-movement logic depends on, integration tests for real interaction with the database and other collaborators, and don’t let either one pretend to be the other. Run those integration tests inside a transaction that rolls back at the end — it’s faster, it isolates tests from each other without manual cleanup, and it lets you test against realistic constraints without leaving a mess for the next test to trip over.

testing · payments · engineering-practice

30 minutes with a senior engineer.

Tell us what you're building. You'll leave with an honest opinion, even if it's "you don't need us."

Reference calls with past clients are available under NDA during evaluation.