Testing Cloudflare Workers locally is a surprisingly tricky business, and the common advice to just "run your Worker code in Node.js" misses the fundamental reason Workers exist: they aren’t Node.js.
Let’s see what a simple "Hello, World!" Worker looks like and how we can get it to respond to requests.
// index.js
export default {
async fetch(request) {
return new Response(`Hello from ${request.url}`);
},
};
Now, let’s pretend we’re running this. If you were to just node index.js, you’d get a JavaScript error because Response and request objects aren’t defined in a standard Node.js environment. Cloudflare Workers run on V8 isolates, which provide a specific set of Web APIs (like fetch, Response, Request, Headers, URL, crypto, etc.) that are not identical to Node.js globals or the browser’s window object.
This is where Miniflare comes in. Miniflare is a local development server and testing tool that emulates the Cloudflare Workers runtime. It provides the necessary Web APIs and allows you to run your Worker code as if it were deployed on Cloudflare’s edge.
To make this concrete, let’s set up a Vitest project. Vitest is a fast, modern test runner that’s built on Vite.
First, we need to install our dependencies:
npm install -D vitest miniflare @cloudflare/workers-types
@cloudflare/workers-types provides TypeScript definitions for the Cloudflare Workers environment, which is incredibly helpful for catching type errors.
Next, we’ll create a vite.config.ts file to configure Vitest.
// vite.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'miniflare',
environmentOptions: {
// You can configure Miniflare options here.
// For example, to mock KV namespaces:
// kvNamespaces: { MY_KV_BINDING: 'path/to/local/kv.json' }
},
},
});
The key here is environment: 'miniflare'. This tells Vitest to use Miniflare as its test environment, providing the correct global APIs.
Now, let’s write a test for our index.js Worker. We’ll create a index.test.ts file.
// index.test.ts
import { describe, it, expect } from 'vitest';
describe('Worker', () => {
it('should respond with a greeting', async () => {
const request = new Request('http://localhost:8787/');
const response = await self.ctx.fetch(request); // Use `self.ctx.fetch` in Miniflare env
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe('Hello from http://localhost:8787/');
});
});
Notice we’re using self.ctx.fetch. In the Miniflare Vitest environment, self.ctx provides access to the Miniflare context, which includes a fetch method that can be used to make requests to your Worker. This is how you simulate incoming requests to your Worker.
To run this test, you’d add a script to your package.json:
// package.json
{
"scripts": {
"test": "vitest"
}
}
Then, run npm test.
When Vitest runs, it will spin up Miniflare in the background. Because we’ve set environment: 'miniflare', Vitest automatically injects the necessary globals like Response, Request, and fetch (which self.ctx.fetch uses internally). This means your Worker code, which expects these globals, will run without errors. The self.ctx.fetch call then passes the simulated request object to your Worker’s fetch handler, and you get the Response back to assert against.
The real power comes when you start using Cloudflare-specific features. For instance, if your Worker needs to interact with a KV namespace:
// index.js
export default {
async fetch(request, env, ctx) {
const value = await env.MY_KV_BINDING.get('my-key');
if (!value) {
return new Response('Key not found', { status: 404 });
}
return new Response(`Value: ${value}`);
},
};
To test this, you’d configure vite.config.ts with a mock KV namespace:
// vite.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'miniflare',
environmentOptions: {
kvNamespaces: { MY_KV_BINDING: { 'my-key': 'my-value' } }
},
},
});
And update your test:
// index.test.ts
import { describe, it, expect } from 'vitest';
describe('Worker with KV', () => {
it('should return value from KV', async () => {
const request = new Request('http://localhost:8787/');
const response = await self.ctx.fetch(request);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe('Value: my-value');
});
it('should return 404 if key not found', async () => {
// Miniflare's KV is reset between tests by default if not persisted.
// For this test, we assume the KV is empty for 'non-existent-key'.
const request = new Request('http://localhost:8787/non-existent-key'); // Assuming URL path maps to key
const response = await self.ctx.fetch(request);
expect(response.status).toBe(404);
const text = await response.text();
expect(text).toBe('Key not found');
});
});
Miniflare’s environmentOptions allow you to mock various Cloudflare bindings like KV namespaces, Durable Objects, R2 buckets, and more. The kvNamespaces option takes an object where keys are the binding names (e.g., MY_KV_BINDING) and values are either a path to a JSON file or an object representing the key-value pairs. When you use env.MY_KV_BINDING.get('my-key') in your Worker, Miniflare intercepts this and returns the mocked value.
One of the most subtle aspects of testing Workers is understanding the self.ctx object. It’s not just a wrapper around fetch; it’s the gateway to the Miniflare runtime’s capabilities. For instance, if your Worker uses ctx.waitUntil(), you’d typically see it in a test like this:
// index.js
export default {
async fetch(request, env, ctx) {
ctx.waitUntil(Promise.resolve(console.log('Doing background work...')));
return new Response('Hello');
},
};
// index.test.ts
import { describe, it, expect, vi } from 'vitest';
describe('Worker with waitUntil', () => {
it('should execute waitUntil tasks', async () => {
const consoleSpy = vi.spyOn(console, 'log');
const request = new Request('http://localhost:8787/');
const response = await self.ctx.fetch(request);
expect(response.status).toBe(200);
// In Miniflare's Vitest environment, waitUntil tasks are executed
// after the fetch handler completes but before the test finishes.
// We might need a small delay or a mechanism to ensure execution.
// For simplicity here, we assume it runs.
expect(consoleSpy).toHaveBeenCalledWith('Doing background work...');
consoleSpy.mockRestore();
});
});
Miniflare’s Vitest environment is designed to execute waitUntil promises automatically after the main fetch handler returns, ensuring that background tasks are accounted for. This is a critical difference from trying to run this logic in a plain Node.js script where waitUntil would simply be ignored.
The next step in your local development workflow will be integrating a service worker pattern, perhaps using a service-worker.js file alongside your index.js, and understanding how Miniflare handles multiple entry points.