Writing tests is advice I give and haven't followed everywhere. CardBridge has no automated test suite. The case study says so, and I've said it twice already in this series.
So here's the plan, publicly, so I'm accountable. It's also a worked example of how to approach testing a project that grew without tests, which is a common situation.
Start with the scariest code
Trying to test everything at once is why retrofitted tests never happen. Pick the code where a bug costs the most.
For CardBridge that's the escrow state machine: the function and triggers that decide whether a transaction may move from one state to another. If a bad transition slips through, the project's core promise (nobody can skip a step) is broken.
Why this is testable
Because the rules are in the database (Day 2), they have a clear interface: call transition_transaction as a given user with a given current state, and either it works or it raises. That's the kind of code that's pleasant to test.
Step 1: table-driven transition tests
List every (from, to, role) pair and say whether it should succeed. The test is just a loop over the table.
const cases: [from: string, to: string, role: string, ok: boolean][] = [ ['MATCHED', 'FUNDED', 'requester', true ], ['MATCHED', 'DELIVERED', 'requester', false], // can't skip steps ['FUNDED', 'PURCHASED', 'cardholder', true ], ['FUNDED', 'PURCHASED', 'requester', false], // wrong role ['DISPUTED', 'RELEASED', 'cardholder', false], // frozen by dispute ['RELEASED', 'REFUNDED', 'admin', false], // terminal state ]; describe.each(cases)('%s -> %s as %s', (from, to, role, ok) => { it(ok ? 'is allowed' : 'is refused', async () => { const tx = await seedTransaction({ status: from }); const run = () => transitionAs(role, tx.id, to); if (ok) await expect(run()).resolves.not.toThrow(); else await expect(run()).rejects.toThrow(/illegal transition/); }); }); The value is in the table. It forces me to write down, explicitly, every rule I'd been carrying in my head. Gaps in the table are gaps in my understanding.
Step 2: concurrency
State machines get dangerous under races. Two requests arrive at nearly the same moment, both read FUNDED, both try to move on. The question is whether exactly one wins.
it('lets exactly one of two simultaneous transitions win', async () => { const tx = await seedTransaction({ status: 'FUNDED' }); const results = await Promise.allSettled([ transitionAs('cardholder', tx.id, 'PURCHASED'), transitionAs('cardholder', tx.id, 'PURCHASED'), ]); const wins = results.filter(r => r.status === 'fulfilled').length; expect(wins).toBe(1); expect(await eventLogCount(tx.id, 'PURCHASED')).toBe(1); // one log row, not two }); I'm not inventing this approach. My Job Scheduler project has a check in this spirit: 300 jobs claimed by 10 concurrent claimers, with zero double-claims. If it works for a queue, it can work for an escrow.
Step 3: policy tests
Row Level Security needs its own tests (Day 17). For each table, test three callers:
- the owner can read and change their rows,
- an authenticated stranger sees nothing,
- an anonymous caller sees nothing.
it('hides a transaction from an unrelated user', async () => { const tx = await seedTransaction({ status: 'MATCHED' }); const rows = await selectAs('stranger', 'transactions', { id: tx.id }); expect(rows).toHaveLength(0); }); Step 4: the event log invariants
The immutable event log should satisfy simple invariants worth asserting:
- every successful transition produces exactly one log row,
- log rows can't be updated or deleted,
- a refused transition produces no row.
Why start here
Testing everything at once never happens. Testing the riskiest 10% is achievable in an afternoon, and it protects the code that matters most.
The order I'll tackle it in
- Set up a throwaway local database and a seed helper.
- Table-driven transition tests (Step 1).
- Concurrency test (Step 2).
- Policy tests for the two or three most sensitive tables (Step 3).
- Hook all of it into one
npm testcommand and a CI run.
What this won't cover
I want to be clear about what a plan like this doesn't give:
- No end-to-end browser tests of the interface.
- Stubbed escrow and KYC providers mean the integrations aren't tested against reality.
- Tests prove the cases I thought of. They can't prove I thought of all of them.
A reusable rule
If a rule matters, it deserves a test that tries to break it.
That's the same philosophy as the Whiteboard.ai approach: every guarantee gets a sentence and a test that tries to violate it.
Setting up a throwaway test database
Tests that touch a database need a database that's safe to wreck. Two common options:
Option A: Docker Postgres
docker run --name testdb -e POSTGRES_PASSWORD=test -p 54329:5432 -d postgres:16 # apply your migrations in order against postgres://postgres:test@localhost:54329/postgres Option B: Supabase local stack (needs Docker as well)
npx supabase start # starts a local Postgres, auth and storage npx supabase db reset # re-applies all migrations from scratch With ordered migrations (CardBridge has fourteen), a reset rebuilds the schema exactly as production has it, which is one of the less obvious benefits of keeping everything as versioned SQL.
A seed helper
Tests are easier to read when setup is one line:
async function seedTransaction(overrides: Partial<Transaction> = {}) { const requester = await createUser('requester'); const cardholder = await createUser('cardholder'); return insertTransaction({ requester_id: requester.id, cardholder_id: cardholder.id, status: 'MATCHED', ...overrides, }); } Keep tests isolated
- Each test creates its own data. Don't depend on another test's leftovers.
- Reset between tests by truncating tables or wrapping each test in a transaction you roll back.
- Don't share mutable state across files.
- Fix random seeds and clocks when behaviour depends on them.
Common pitfalls in database tests
| Pitfall | Why it hurts | Fix |
|---|---|---|
| Tests depend on order | One failure cascades | Isolate data per test |
| Asserting only "no error" | Passes when nothing happened | Assert on rows and counts |
| Testing as a superuser | Bypasses RLS, so tests pass wrongly | Test as the real roles |
| Mocking the database | Misses constraint and trigger behaviour | Use a real Postgres |
| Slow suite | You stop running it | Share one DB, isolate with transactions |
The third row is the sneakiest: if your test connection is an admin, row-level policies don't apply, and a broken policy looks fine.
Putting it in CI
A minimal GitHub Actions workflow for a Postgres-backed suite:
name: test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: { POSTGRES_PASSWORD: test } ports: ['5432:5432'] options: >- --health-cmd pg_isready --health-interval 5s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20 } - run: npm ci - run: npm test env: DATABASE_URL: postgres://postgres:test@localhost:5432/postgres Adjust to your project. The point is that a rule can't be broken without a red cross on the pull request.
How I'll know it worked
I'll consider the first milestone done when:
- one command runs the whole suite locally,
- CI runs it on every push,
- and I can deliberately break a rule (say, allow a skipped state) and watch a test fail.
That last step, breaking it on purpose, is the one people skip. A test that has never failed hasn't proven it can.
Beyond the first pass
After the core rules are covered, the next layers would be end-to-end browser tests for the main flow and tests with real (sandbox) providers for payments and identity. Those depend on integrations that are stubbed today, so they come later.
I'll write a follow-up when the suite exists. If you've tested Postgres triggers in a way you like, I'd genuinely love tips in the comments.
☕ Enjoyed this post? You can support my writing on Ko-fi.

