Testing Cloudflare Workers Without Cloudflare: A Vitest Setup That Actually Works

작성자

카테고리:

← 피드로
DEV Community · Anand Rathnas · 2026-07-23 개발(SW)

Anand Rathnas

This article was originally published on Jo4 Blog.

I added 3,059 lines of tests to our Cloudflare Worker. Not a single line uses miniflare. No Workers runtime simulation. Just Vitest, vi.stubGlobal('fetch', mockFetch), and focused assertions on the things that actually break in production.

Here’s why that’s the right call and how to set it up.

TL;DR

Cloudflare Workers are just JavaScript functions that receive a Request and return a Response. You don’t need the Workers runtime to test most of the logic. Stub fetch, write unit tests for routing, caching headers, and redirect handling. Save integration tests for deployment verification. This approach is faster, simpler, and catches the bugs that miniflare would miss anyway.

The Setup

A Cloudflare Worker’s entry point looks like this:

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);

    if (url.pathname.startsWith('/go/')) {
      return proxyTrackingRequest(request, env);
    }

    if (url.pathname.startsWith('/blog/')) {
      return proxyBlogRequest(request, env);
    }

    return proxyDefaultRequest(request, env);
  }
};

Enter fullscreen mode Exit fullscreen mode

It’s a function. It takes a Request. It returns a Response. You can test this without Cloudflare’s runtime.

Here’s the Vitest setup:

// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    globals: true,
  },
});

Enter fullscreen mode Exit fullscreen mode

No special Workers environment. No miniflare plugin. Just Node.

Stubbing Fetch

The Worker delegates to your backend via fetch. Stub it:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import worker from '../src/index';

describe('Cloudflare Worker', () => {
  let mockFetch;

  beforeEach(() => {
    mockFetch = vi.fn();
    vi.stubGlobal('fetch', mockFetch);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });
});

Enter fullscreen mode Exit fullscreen mode

vi.stubGlobal('fetch', mockFetch) replaces the global fetch that the Worker calls internally. Your tests control exactly what the backend returns.

The Test That Caught a Production Bug

This is the test I’m most proud of:

it('does NOT set Cache-Control on tracking routes - clicks must not be cached', async () => {
  mockFetch.mockResolvedValue(new Response('', {
    status: 302,
    headers: { 'Location': 'https://merchant.com/product' }
  }));

  const request = new Request('https://jo4.io/go/abc123');
  const response = await worker.fetch(request, mockEnv, mockCtx);

  // Verify no cache headers on tracking routes
  expect(response.headers.get('Cache-Control')).toBeNull();
  expect(response.headers.get('CDN-Cache-Control')).toBeNull();
});

Enter fullscreen mode Exit fullscreen mode

Why does this matter? Our tracking links (/go/*) record clicks. Every hit to /go/abc123 creates a unique jo4_cid (click ID) and records the visit. If Cloudflare caches the 302 redirect, subsequent clicks skip the backend entirely. The publisher’s click count flatlines. The brand sees zero conversions. Everyone loses money.

Blog routes get cached aggressively. Tracking routes must never be cached. One misplaced Cache-Control header and affiliate tracking silently breaks.

This test runs in 2ms. It would catch a regression the moment someone refactors the proxy logic and accidentally applies caching globally.

Testing Redirect Handling

Another critical test: verifying the Worker doesn’t follow redirects.

it('passes redirect: manual to fetch for tracking routes', async () => {
  mockFetch.mockResolvedValue(new Response('', {
    status: 302,
    headers: { 'Location': 'https://merchant.com/product' }
  }));

  const request = new Request('https://jo4.io/go/abc123');
  await worker.fetch(request, mockEnv, mockCtx);

  // The Worker must NOT follow the redirect itself
  expect(mockFetch).toHaveBeenCalledWith(
    expect.any(String),
    expect.objectContaining({
      redirect: 'manual'
    })
  );
});

Enter fullscreen mode Exit fullscreen mode

Without { redirect: 'manual' }, the Worker follows the 302 to the merchant’s site. The fetch call resolves to the merchant’s HTML page instead of the redirect response. The click event is recorded, but the Worker returns a 200 with the merchant’s HTML instead of a 302 that the browser follows. The user sees the merchant page inside the Worker’s response context. It’s a mess.

redirect: 'manual' tells fetch to return the 302 as-is. The Worker passes the redirect response through to the browser. The browser follows it. The click is tracked AND the redirect works correctly.

Testing Cache Headers on Blog Routes

The inverse of the tracking test: blog routes SHOULD be cached.

it('sets Cache-Control on blog routes', async () => {
  mockFetch.mockResolvedValue(new Response('<html>Blog post</html>', {
    status: 200,
    headers: { 'Content-Type': 'text/html' }
  }));

  const request = new Request('https://jo4.io/blog/some-post/');
  const response = await worker.fetch(request, mockEnv, mockCtx);

  expect(response.headers.get('Cache-Control')).toBeTruthy();
});

Enter fullscreen mode Exit fullscreen mode

Blog content is static. Caching it at the edge reduces origin load and improves page speed. The test ensures blog routes always get cache headers, even after refactoring.

Simplifying the Production Code

Writing these tests exposed unnecessary complexity in the Worker itself. The proxyRequest function had a conditional cache flag:

// Before: conditional caching logic
async function proxyRequest(request, env, shouldCache) {
  const response = await fetch(targetUrl, {
    redirect: shouldCache ? 'follow' : 'manual',
    // ... other options
  });

  if (shouldCache) {
    response.headers.set('Cache-Control', 'public, max-age=3600');
  }

  return response;
}

Enter fullscreen mode Exit fullscreen mode

After writing the tests, I realized the caching decision is entirely determined by the route type. There’s no scenario where a tracking route should be cached or a blog route shouldn’t. The conditional flag was adding complexity without flexibility.

// After: separate functions, no conditionals
async function proxyTrackingRequest(request, env) {
  return fetch(targetUrl, { redirect: 'manual' });
  // No cache headers. Ever.
}

async function proxyBlogRequest(request, env) {
  const response = await fetch(targetUrl);
  response.headers.set('Cache-Control', 'public, max-age=3600');
  return response;
}

Enter fullscreen mode Exit fullscreen mode

Simpler code. Easier to test. Impossible to accidentally cache a tracking route.

Why Not Miniflare?

Miniflare is excellent for integration testing. It simulates the Workers runtime, KV stores, Durable Objects, and other Cloudflare primitives. If your Worker uses KV heavily or relies on Durable Objects, miniflare is the right choice.

But for our Worker, the logic is straightforward: route requests, set headers, handle redirects. The bugs that bite us are:

  1. Cache headers on the wrong routes
  2. Missing redirect: 'manual' on tracking requests
  3. Incorrect path matching

None of these require a simulated runtime. They’re logic bugs. Unit tests with mocked fetch catch them instantly.

Miniflare adds startup time, dependencies, and configuration complexity. For 3,059 lines of tests, the faster feedback loop of pure Vitest matters.

The Numbers

Metric Value Lines of tests added 3,059 Test execution time ~1.2 seconds Dependencies added 0 (Vitest was already installed) Miniflare config files 0 Production bugs caught by cache header tests 1 (and counting)

The Pattern

If your Cloudflare Worker is primarily a proxy that routes, caches, and redirects:

  1. Use vi.stubGlobal('fetch', mockFetch) to control backend responses
  2. Test cache headers per route type explicitly
  3. Test redirect handling with redirect: 'manual' assertions
  4. Keep tests fast — no runtime simulation needed
  5. Save miniflare for Workers that use KV, D1, or Durable Objects

The goal isn’t 100% simulation fidelity. It’s catching the bugs that cost money. A cached tracking link is a silent revenue killer. A test that runs in 2ms and prevents it is worth more than a full miniflare integration suite that takes 30 seconds to boot.

How do you test your Cloudflare Workers? Miniflare, Vitest, or something else? I’m genuinely curious if others have gone the lightweight route.

Building jo4.io — affiliate tracking links that are tested to never be cached.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다