SHARKPARTY
K Kam avatar Kam

Testing Workers Against Real Bindings

RORBT cloudflare testing engineering software

For most of a long build session, every feature I shipped ended with the same caveat, delivered to myself in the commit message like a doctor hedging a diagnosis: “the logic deciding whether to do X is thoroughly tested; the code that actually does X is typechecked only.” That is a miserable place to be when the product is governance. “The vote executed correctly” is not a feature of this system—it is the entire value proposition. And yet the pure predicates had 40+ tests while the write paths—the ones that assign roles, suspend members, erase accounts—had none, because exercising them needs env with real D1 and R2, and I kept deferring the harness the way you defer flossing.

This is part 6 of 10 in the RORBT field notes—dispatches from building a governance platform on Cloudflare. The whole series lives on the RORBT case study.

A predicate with forty tests guarding a write path with zero is a locked door in an open field.

This post is about closing that gap, and about the four things that went wrong on the way—because of course four things went wrong on the way.

@cloudflare/vitest-pool-workers runs tests inside workerd

Not a mock. Not miniflare-as-a-library. The actual runtime, with actual bindings:

// vitest.config.mts
import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-pool-workers';

export default defineConfig({
  plugins: [
    cloudflareTest(async () => ({
      miniflare: {
        compatibilityDate: '2026-01-01',
        compatibilityFlags: ['nodejs_compat'],
        d1Databases: { DB: 'test-db' },
        r2Buckets: ['RORBT_EMAIL_LIST', 'MEMBER_DATA', 'ORBITS'],
        bindings: {
          // Read at config time—Node context, where the filesystem exists.
          TEST_MIGRATIONS: await readD1Migrations(path.join(here, 'drizzle/migrations')),
          BETTER_AUTH_SECRET: 'test-secret-at-least-32-chars-long-x',
          // Deliberately empty: the send paths short-circuit on a missing token,
          // so no test can dispatch real email or SMS.
          CF_EMAIL_API_TOKEN: '',

        },
      },
    })),
  ],
  test: { include: ['test/**/*.test.ts'], setupFiles: ['./test/setup.ts'] },
});

Then in a test:

import { env } from 'cloudflare:test';

await env.ORBITS.put('harness/probe.json', JSON.stringify({ ok: true }));
const got = await env.ORBITS.get('harness/probe.json');

I want to dwell on how strange this is if you’re arriving from AWS, as I was. The closest equivalents over there are LocalStack—a reimplementation that drifts from the real thing at exactly the moments you’d most like it not to—or testcontainers plus real AWS, which is slow, costs money, and needs credentials. This is neither. It’s the production runtime, in-process, starting in under a second. I kept waiting for the catch.

Bindings are declared inline rather than read from wrangler.toml—partly because that file is Pages-shaped (pages_build_output_dir) where the pool expects a Worker config, and partly because it keeps local-dev overrides out of tests entirely. A pleasant surprise: the harness needs no changes to wrangler.toml at all.

Four things that went wrong

The package is ESM-only; my repo root is CJS

✘ [ERROR] Missing "./config" specifier in "@cloudflare/vitest-pool-workers"
✘ [ERROR] This package is ESM only but it was tried to load by `require`

Two separate problems wearing one error log. The /config subpath and defineWorkersConfig are from an older major—in the version I installed, cloudflareTest and readD1Migrations come from the root export. And the config file is loaded as CJS unless the filename says otherwise.

Fix: name it vitest.config.**mts**. Flipping "type": "module" in package.json would have rippled through scripts/*.mjs, drizzle.config.ts, and the workspace tooling for no benefit—a whole-repo surgery to solve a one-file problem.

Lesson: check the installed version’s actual exports before following a blog post—including this one.

Storage isolation is per-file, not per-test

I did something I recommend without reservation: I wrote a smoke test asserting the harness’s own guarantees before trusting it with anything real. One failed:

it('starts from clean storage—the previous test is not visible here', async () => {
  expect(await env.ORBITS.get('harness/probe.json')).toBeNull();   // ← FAILED
});

The object written by the previous test was still there. isolatedStorage doesn’t exist in this version of the pool. Isolation is per test file.

The easy move was deleting the assertion and moving on. But a suite that leaks state produces order-dependent passes, and an order-dependent pass is the worst kind of green—it tells you the tests ran, not that the code works. So instead of deleting the guarantee, I made it real:

export async function resetStorage(env: Env): Promise<void> {
  // Tables discovered from sqlite_master rather than listed, so this never
  // drifts as migrations add tables. d1_migrations is preserved.
  const { results } = await env.DB.prepare(
    `select name from sqlite_master
      where type='table' and name not like 'sqlite_%'
        and name not like '_cf_%' and name != 'd1_migrations'`).all<{ name: string }>();

  await env.DB.batch(results.map((r) => env.DB.prepare(`delete from "${r.name}"`)));

  for (const bucket of [env.ORBITS, env.MEMBER_DATA, env.RORBT_EMAIL_LIST]) {
    let cursor: string | undefined;
    do {
      const page = await bucket.list({ cursor });
      if (page.objects.length) await bucket.delete(page.objects.map((o) => o.key));
      cursor = page.truncated ? page.cursor : undefined;
    } while (cursor);
  }
}

The dynamic table discovery means it never needs maintenance. And it uses batch()—a half-truncated database is a worse starting point than a full one.

Lesson: write tests that assert your harness’s guarantees. Mine failed immediately and told me something true.

Seeding a real authenticated session

This was the expensive part, and the whole point. requireVerifiedUser reads a better-auth session cookie, which left me two options:

  • Insert a user row directly. Fast, and wrong—it skips the sign-up hook that mints the handle, normalizes the phone to E.164, and parks pending invites. Tests would exercise a user shape production never creates, which is a polite way of saying they’d test a fictional character.
  • Drive better-auth’s own endpoints. Slower, and correct.

I took the second:

export async function signUpAndSession(env: Env): Promise<SeededUser> {
  // Uses the `skipCaptcha` seam that already exists for attested mobile
  // clients—no new production seam added for tests.
  const auth = buildAuth(env, { skipCaptcha: true });

  const signUp = await auth.handler(new Request('http://localhost/api/auth/sign-up/email', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', origin: 'http://localhost',
               'x-forwarded-for': ip, 'cf-connecting-ip': ip },
    body: JSON.stringify({ email, password, phoneNumber, name: 'placeholder' }),
  }));

  // requireVerifiedUser gates on this; the real path emails a link.
  await db.update(user).set({ emailVerified: true }).where(eq(user.id, row.id));

  // autoSignIn is off in the auth config, so sign in explicitly.
  const signIn = await auth.handler(new Request('…/sign-in/email', { … }));
  return { …, cookie: cookieHeader(signIn) };
}

Note it lives in test/support/ as an exportable module, not buried in config—because the sibling E2E repo needs exactly the same thing, and solving it twice guarantees divergence. Two copies of a seeding helper is one copy of a future bug.

My own rate limiter fought me

Seeding several users in one test:

Error: sign-up failed (429): {"message":"Too many requests. Please try again later."}

better-auth’s limiter: 10 sign-ups per minute per IP. Every seeded user shared the same (absent) IP. My own security posture, working exactly as configured, against me.

The tempting fix is disabling the limiter in tests. I gave each seeded user its own source address instead:

// better-auth throttles sign-up at 10/min per IP, and seeded users would
// otherwise all share one. Giving each its own source address exercises the
// real limiter instead of switching it off—a suite that runs with
// protections disabled proves less than one that doesn't.
ip: `203.0.113.${(seq % 254) + 1}`,   // TEST-NET-3, RFC 5737
A suite that runs with the protections switched off is testing a product you don’t ship.

What it bought

The very first integration test found nothing—the founding-window bypass worked. That’s fine; that’s the test doing its job, and finding nothing is a result, not a disappointment. But within an hour the harness had earned itself several times over:

  • Caught that the bypass path left executed_at NULL, meaning every later read would re-drive it and append a second transparency-log receipt on top of the one it already wrote.
  • Caught that a split resume with a bogus spawnedId completed silently instead of failing (post 04).
  • Verified the erasure cascade—the one operation that permanently destroys a person’s data—actually cascades.
graph LR
  subgraph Before
    A[40 pure-function tests<br/>predicates only] --> B[write paths:<br/>typechecked only]
    style B fill:#7f1d1d,color:#fff
  end
  subgraph After
    C[132 tests] --> D[predicates]
    C --> E[handlers with real<br/>D1 + R2 + sessions]
    style E fill:#14532d,color:#fff
  end

132 tests in ~6 seconds, including sign-up flows against real SQLite.

The design property that made it possible

I’d love to claim the harness succeeded because I’m good at harnesses. The truer story is that it succeeded because of a decision made months earlier, before testing was on my mind at all. None of this would have worked if _lib looked like typical service code. Because every function takes env as its first argument and has no module-level state, they’re all trivially injectable:

export async function withdrawProposal(env: Env, input: {…}): Promise<ProposalRow>
export async function planSplit(orbit: OrbitRecord): Promise<SplitPlan>
export function canBypassRoleVote(orbit: OrbitRecord, personId: string, now = Date.now()): boolean

Note now = Date.now() as a defaulted parameter—that one convention makes every time-dependent rule testable without mocking the clock. Testing “the founding window closes at exactly 30 days” is a function call, not a fake-timers dance.

The general lesson: separate pure decisions from effectful execution, pass capabilities explicitly, default your clock. Do that and the expensive harness becomes optional for most of your logic—and genuinely valuable for the rest. The harness didn’t make the code testable. The code made the harness cheap.


Previous: Durable Objects Are the Actor Model Wearing a Database Costume · Next: better-auth After Auth0