Cloudflare Workers
Testing
Vitest
Miniflare
CI/CD

Testing Workers: unit, integration and how to simulate the runtime without depending on Cloudflare

The Workers runtime is not Node.js — testing without taking this into account ensures that tests pass locally and bugs appear in production.

Testing Workers: unit, integration and how to simulate the runtime without depending on Cloudflare

Testing Workers with Jest running on Node.js is an elegant trap. The tests pass, the CI turns green, and the code arrives in production with bugs that the testing environment could never detect — because the testing environment is not the runtime that runs the code in production. Workers run on V8 isolates with the web platform APIs, not the Node.js APIs: there is no Buffer, there is no process.env, the fetch is the native implementation of the runtime (not the node-fetch nor the undici), and crypto is the Web Crypto API with its asynchronous signatures. A fetch mock written for Jest in Node.js may pass the tests and silently not work when the Worker is dealing with the real Response from the edge.

Why the testing environment matters more than it seems

The difference between Node.js and the Workers runtime goes beyond the available APIs: edge behavior only appears under specific conditions. The TextEncoder in Node.js and the TextEncoder in the Workers runtime have the same interface, but the Uint8Array they produce may behave differently when passed to a SubtleCrypto operation depending on version and implementation. Workers Headers has a header name normalization behavior (case-insensitive, lexicographic order) that differs from mock implementations.

Most importantly: bindings do not exist in Node.js. env.KV, env.DB, env.BUCKET are objects that the runtime injects — there is no way to import an npm package that provides them with full fidelity. Manual mocks test the code that calls the mock, not the code that will work with the actual binding.

@cloudflare/vitest-pool-workers solves this by running the tests inside a real Workers runtime — specifically Miniflare v3, which runs a real V8 isolate, not a Node.js simulation. Tests have access to caches, crypto, fetch, Headers, ReadableStream, TransformStream with runtime implementations. Bindings are provided as in-process implementations: KV in memory, D1 as embedded SQLite, R2 as local storage. The result is that a test that passes with vitest-pool-workers is testing code that will work in the real runtime.

Configuring vitest.config.ts with bindings

The Workers pool configuration in vitest.config.ts is where you declare the bindings that the tests will receive. The structure mirrors wrangler.toml, but in the Vitest configuration:

import { defineConfig } from 'vitest/config'; import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; export default defineWorkersConfig({ test: { poolOptions: { workers: { wrangler: { configPath: './wrangler.toml' }, miniflare: { kvNamespaces: ['CACHE'], d1Databases: ['DB'], r2Buckets: ['BUCKET'], bindings: { APP_ENV: 'test', }, }, }, }, }, });

With this configuration, each test suite receives a clean environment with an empty KV namespace, an empty D1 SQLite database (with the applied schema if you configure migrations), and an empty R2 bucket. Bindings are isolated between suites — changes made by tests in one suite do not leak to another.

To access the bindings in tests, @cloudflare/vitest-pool-workers exports a helper env that Miniflare injects via a global mechanism:

import { env } from 'cloudflare:test'; test('deve retornar 404 para usuário inexistente', async () => { const request = new Request('https://api.example.com/users/999'); const response = await SELF.fetch(request); expect(response.status).toBe(404); });

SELF is the binding of the Worker itself under test — you can do SELF.fetch() to test the fetch handler as an integration HTTP call, with full routing and all available bindings.

The division between unit and integration testing

Business logic functions that operate on pure JavaScript values — parsing a JWT, calculating a discounted price, validating an input schema — can and should be tested without the Workers pool. These functions do not depend on runtime APIs or bindings, and testing them in pure Node.js with standard Vitest is faster and simpler. The rule of thumb: If the function takes and returns JavaScript primitive values ​​or flat objects, it belongs to the Node.js pool. If it uses Request, Response, Headers, ReadableStream, or any binding, it belongs to the Workers pool.

Integration HTTP handlers — the fetch(request, env, ctx) function that is the Worker entry point — need the Worker pool to be tested faithfully. Testing an integration handler means going SELF.fetch(new Request(...)) and checking the status, response headers, and body of the response that the Worker would produce in production. This test also has a side effect on bindings: if the handler should save a record in D1, the test can do env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId).first() later and verify that the data was written correctly.

CI without Cloudflare credentials — and the limits of Miniflare

vitest-pool-workers with Miniflare runs completely local — doesn’t make any calls to the Cloudflare API, doesn’t need CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN in the CI. Miniflare v3 downloads workerd (Cloudflare's open-source runtime) as a local binary and runs it like any other process. The CI pipeline boils down to npm ci and npx vitest run — no secrets, no network dependency beyond binary download on first install.

Miniflare v3 simulates the runtime with high fidelity, but there are documented exceptions that matter to the testing strategy. Durable Objects in production mode — with their global singleton guarantee — are not simulated with distributed consistency; Miniflare creates a local instance useful for basic logic, but does not test actual coordination behavior. CDN behaviors like edge caching and the cf object with real geolocation data need wrangler dev --remote to be exercised.

For these cases, the answer is staging smoke tests that make direct HTTP calls to a staging Worker after deployment. They don't replace Miniflare testing — they cover what Miniflare can't simulate: behaviors that only exist when code runs on real infrastructure.

Testing waitUntil and background work

The ctx.waitUntil() is where work happens after the response has been sent. The SELF.fetch() in the tests returns the answer immediately, but the work passed to waitUntil may not be finished. @cloudflare/vitest-pool-workers provides waitOnExecutionContext to resolve this:

import { env, SELF, waitOnExecutionContext } from 'cloudflare:test'; test('deve enfileirar analytics em background', async () => { const ctx = createExecutionContext(); const response = await SELF.fetch(new Request('https://api.example.com/checkout')); await waitOnExecutionContext(ctx); const queued = await env.ANALYTICS_QUEUE.read(); expect(queued).toHaveLength(1); });

Without waitOnExecutionContext, a test that checks for the side effect of waitUntil will have a race condition — flakiness that is difficult to diagnose because the problem is not in the production code, it is in the test code.

Also read