Test Your Worker Locally

CloudflareBeginner
Practice Now

Introduction

A support API accepts valid requests but crashes on malformed JSON. You will turn that report into a failing test, repair the parsing boundary, and expand the suite to preserve validation and upstream error handling.

This independent VM supplies a small API based on the routing concepts from Build a Support Request API, with one intentional regression. Node.js 22.22.0, Wrangler 4.131.1 and Miniflare 4.20260730.0 are prepared in /home/labex/project/worker-tests. You will write the tests yourself using Node's built-in runner and execute the handler in workerd through Miniflare. Basic JavaScript and the previous API lesson are prerequisites; test assertions, hooks and fixture isolation are explained here.

This is a local-only executable lab. No Cloudflare account authorization, remote resource or previous VM is needed. All Worker outbound requests are intercepted by a local fixture, and test runtimes are disposed after each case.

Write Tests Against the Workers Runtime

In this step, you will build a small test suite around the supplied support API. Tests run in Node's test runner, but requests execute inside Miniflare's workerd runtime instead of importing the handler directly into Node.

Enter the independent project and inspect the supplied handler and pinned dependencies:

cd /home/labex/project/worker-tests
node --version
npx wrangler --version
npm ls miniflare --depth=0
cat src/index.js

Expect Node v22.22.0, Wrangler 4.131.1 and a direct Miniflare 4.20260730.0 dependency. These tools are preinstalled. On your own machine, add the exact test dependency with npm install --save-dev miniflare@4.20260730.0; use npm ci for an existing lockfile. This lab pins the 4.x API and its supported compatibility date deliberately rather than relying on an evolving latest tag.

Create test/support.test.mjs. The quoted heredoc writes the module literally. test declares a case, assert.equal checks a scalar, and assert.deepEqual compares structured JSON. Each asynchronous case waits for its response before asserting.

beforeEach starts a new runtime and resets the call list; afterEach disposes the runtime even when a test fails. dispatchFetch sends an in-process test request. Its hostname does not represent a deployed Worker. outboundService intercepts every Worker fetch and returns a local fixture response; it never forwards traffic to the Internet. It checks the intended upstream URL and method and records the normalized request. cf: false disables fetching sample Cloudflare request metadata. No login, remote binding or cloud deployment is used.

cat > test/support.test.mjs <<'JS'
import {test, beforeEach, afterEach} from 'node:test';
import assert from 'node:assert/strict';
import {fileURLToPath} from 'node:url';
import {Miniflare} from 'miniflare';

let mf;
let calls;
beforeEach(() => {
  calls = [];
  mf = new Miniflare({
    modules: true,
    scriptPath: fileURLToPath(new URL('../src/index.js', import.meta.url)),
    compatibilityDate: '2026-07-30',
    cf: false,
    bindings: {UPSTREAM_URL: 'https://tickets.test'},
    outboundService: async (request) => {
      assert.equal(request.url, 'https://tickets.test/tickets');
      assert.equal(request.method, 'POST');
      const body = await request.json();
      calls.push(body);
      if (body.subject === 'simulate-outage') {
        return new Response('SIMULATED_INTERNAL_DETAIL', {status: 503});
      }
      return Response.json({ticket: `demo-${calls.length}`, subject: body.subject}, {status: 201});
    }
  });
});
afterEach(async () => { await mf.dispose(); });

test('health stays public', async () => {
  const response = await mf.dispatchFetch('http://worker.test/health');
  assert.equal(response.status, 200);
  assert.deepEqual(await response.json(), {status: 'ok'});
  assert.equal(calls.length, 0);
});

test('valid request reaches the local fixture', async () => {
  const response = await mf.dispatchFetch('http://worker.test/requests', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({subject: '  Printer offline  '})
  });
  assert.equal(response.status, 201);
  assert.deepEqual(await response.json(), {ticket: 'demo-1', subject: 'Printer offline'});
  assert.deepEqual(calls, [{subject: 'Printer offline'}]);
});
JS

Run the standard Node test command; --test-reporter=spec prints readable case names and totals:

node --test --test-reporter=spec test/support.test.mjs

Expect two passing tests and zero failures. Health must not call the upstream. The valid request must produce demo-1 and send a trimmed subject exactly once. These initial tests do not cover malformed JSON yet. Use verification to check the suite and independent runtime behavior.

See the Miniflare API and outbound service option for the underlying interfaces. Production network behavior still needs separate deployment testing.

Add a Regression Test That Fails

In this step, you will capture a reported defect: malformed JSON should produce a predictable 400 response, but the starter handler lets a parsing exception escape.

Append one test with >>, which preserves the two existing tests. The body is the incomplete JSON text {. The assertion checks the response status before parsing JSON so the failure clearly identifies the HTTP contract.

cat >> test/support.test.mjs <<'JS'

test('malformed JSON returns 400 before the upstream', async () => {
  const response = await mf.dispatchFetch('http://worker.test/requests', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: '{'
  });
  assert.equal(response.status, 400);
  assert.deepEqual(await response.json(), {error: 'invalid_json'});
  assert.equal(calls.length, 0);
});
JS
node --test --test-reporter=spec test/support.test.mjs

Expect three tests: two pass and the malformed-JSON test fails. The assertion reports actual 500 versus expected 400, and the process exits nonzero. The runtime may also print the underlying parsing exception. This is the intended defect, not a reason to change the expected status to 500. An import error, missing package or failure of an existing case is a different problem.

Inspect the handler's unguarded await request.json(). No request should reach the fixture for invalid JSON. Use verification while the defect is still present: this step specifically checks that the regression test fails and existing cases pass. The next step repairs the implementation.

Repair JSON Parsing Without Weakening the Test

In this step, you will contain only the JSON parsing failure and preserve existing routing, validation and upstream handling. Replace the handler with this complete corrected version. The try/catch around request.json() maps a syntax exception to JSON with HTTP 400. The separate upstream try/catch still handles network or response failures.

cat > src/index.js <<'JS'
export default {
  async fetch(request, env) {
    const path = new URL(request.url).pathname;
    if (path !== '/health' && path !== '/requests') {
      return Response.json({error: 'not_found'}, {status: 404});
    }
    const allowed = path === '/health' ? 'GET' : 'POST';
    if (request.method !== allowed) {
      return Response.json({error: 'method_not_allowed'}, {
        status: 405, headers: {Allow: allowed}
      });
    }
    if (path === '/health') return Response.json({status: 'ok'});
    const mediaType = (request.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
    if (mediaType !== 'application/json') {
      return Response.json({error: 'unsupported_media_type'}, {status: 415});
    }
    let body;
    try {
      body = await request.json();
    } catch {
      return Response.json({error: 'invalid_json'}, {status: 400});
    }
    if (!body || Array.isArray(body) || typeof body.subject !== 'string' ||
        body.subject.trim().length < 1 || body.subject.trim().length > 80) {
      return Response.json({error: 'invalid_subject'}, {status: 422});
    }
    const subject = body.subject.trim();
    try {
      const upstream = await fetch(`${env.UPSTREAM_URL}/tickets`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({subject})
      });
      if (!upstream.ok) {
        return Response.json({error: 'upstream_unavailable'}, {status: 502});
      }
      const ticket = await upstream.json();
      return Response.json({ticket: ticket.ticket, subject}, {status: 201});
    } catch {
      return Response.json({error: 'upstream_unavailable'}, {status: 502});
    }
  }
};
JS
node --test --test-reporter=spec test/support.test.mjs

Expect all three tests to pass, including the unchanged malformed-JSON test. Run the same command again to confirm a fresh test process also passes:

node --test --test-reporter=spec test/support.test.mjs

Both runs should report three passes and zero failures. Each case receives a new runtime and empty fixture call list. Do not disable the failing test or accept a 500 response to make the suite green. Use verification: it checks both the learner tests and a separate set of runtime responses.

Expand Boundary Coverage and Finish Locally

In this step, you will guard against two other regressions: invalid input reaching the dependency, and an upstream outage appearing as a successful request. Append these cases without removing the previous three.

The first test loops over invalid JSON values and asserts 422, then checks unsupported text input for 415. None should call the upstream. The second begins with an empty fixture, simulates a 503 dependency response, and expects the API's contained 502 JSON. Comparing the complete response body also prevents the fixture's internal diagnostic from leaking.

cat >> test/support.test.mjs <<'JS'

test('invalid subjects and media types never reach the upstream', async () => {
  for (const body of [null, [], {}, {subject: 5}, {subject: ' '}, {subject: 'x'.repeat(81)}]) {
    const response = await mf.dispatchFetch('http://worker.test/requests', {
      method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
    });
    assert.equal(response.status, 422);
    assert.deepEqual(await response.json(), {error: 'invalid_subject'});
  }
  const response = await mf.dispatchFetch('http://worker.test/requests', {
    method: 'POST', headers: {'Content-Type': 'text/plain'}, body: 'hello'
  });
  assert.equal(response.status, 415);
  assert.deepEqual(await response.json(), {error: 'unsupported_media_type'});
  assert.equal(calls.length, 0);
});

test('upstream errors are contained with fresh fixture state', async () => {
  assert.equal(calls.length, 0);
  const response = await mf.dispatchFetch('http://worker.test/requests', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({subject: 'simulate-outage'})
  });
  assert.equal(response.status, 502);
  assert.deepEqual(await response.json(), {error: 'upstream_unavailable'});
  assert.deepEqual(calls, [{subject: 'simulate-outage'}]);
});
JS
node --test --test-reporter=spec test/support.test.mjs

Expect five passes and zero failures. Run the suite again; demo-1 and one recorded outage call must remain stable because fixture state is reset for every case.

node --test --test-reporter=spec test/support.test.mjs

All work stayed local: test URLs were dispatched to Miniflare, every outbound Worker call was intercepted, and each runtime was disposed. Confirm the VM has no stored Cloudflare login:

npx wrangler whoami --json

Expect "loggedIn": false; the unauthenticated status command can exit nonzero. Do not sign in for this lab. There are no cloud resources to delete. Use the final verification, which checks the real local API and confirms your tests reject disposable faulty copies as well as accept the repaired code. These assessment copies do not modify your project. Then end the VM.

Local runtime tests make regressions reproducible. They do not verify account ownership, deployment settings, real Internet dependencies or edge rollout behavior; those require the course's remote checks.

Summary

You wrote tests that execute the API in a local Workers runtime, reproduced a 500-versus-400 regression, and repaired the implementation without weakening the expected contract. You added input and upstream-error cases, reset fixture state between cases, and disposed each runtime. The suite remained local and repeatable without cloud credentials or resource writes.