How to test with a mock server

Run a stub HTTP server on a local port, point the client's base URL at it, and assert on what the stub recorded as well as on what the client returned. A stub that answers every route with 200 and a JSON body makes the client take branches it never takes in production.

Why check this

A stub is the only way to reach the branches a real API will not produce on request: a 500, a socket that dies before the first byte, a 204 where the caller expected an object. Add one when you write the first integration test, and revisit it every time the API contract moves.

The failure it prevents is a green suite that proves nothing. The client below reads a user, and the stub answers with 200 and a JSON body that carries no content-type. The client falls back to its plain-text branch, the loose assertion still passes, and the JSON path that production takes has never run.

Prerequisites

import { createServer } from 'node:http';

const hits = [];
const server = createServer((req, res) => {
  if (req.url === '/__hits') {
    return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(hits));
  }
  hits.push(`${req.method} ${req.url}`);
  switch (req.url) {
    case '/user':        // the naive stub: a body, no content-type
      return res.writeHead(200).end('{"id":1,"name":"Ada"}');
    case '/user-typed':
      return res.writeHead(200, { 'content-type': 'application/json' }).end('{"id":1,"name":"Ada"}');
    case '/empty':       // 204 with a body written anyway
      return res.writeHead(204, { 'content-type': 'application/json' }).end('{"id":1,"name":"Ada"}');
    case '/old':
      return res.writeHead(301, { location: '/user-typed' }).end();
    case '/fail':
      return res.writeHead(500, { 'content-type': 'text/html' }).end('<h1>Bad Gateway</h1>');
    case '/slow':
      return setTimeout(() => res.writeHead(200, { 'content-type': 'application/json' }).end('{"id":1,"name":"Ada"}'), 3000);
    case '/reset':
      return req.socket.destroy();
    default:
      return res.writeHead(404).end();
  }
});
server.listen(9691, '127.0.0.1', () => console.log('stub listening on http://127.0.0.1:9691'));
export async function getUser(base, path = '/user') {
  const res = await fetch(new URL(path, base), { signal: AbortSignal.timeout(2000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const type = res.headers.get('content-type') ?? '';
  if (type.includes('application/json')) return { branch: 'json', ...(await res.json()) };
  return { branch: 'text', name: await res.text() };
}
import { expect, test } from 'vitest';
import { getUser } from './client.mjs';

const BASE = 'http://127.0.0.1:9691';

test('the client reads the user name', async () => {
  const user = await getUser(BASE, '/user');
  expect(user.name).toContain('Ada');
});

test('the user name is exactly Ada', async () => {
  const user = await getUser(BASE, '/user');
  expect(user.name).toBe('Ada');
});

Steps

  1. Step 1.

    Start the stub in its own shell.

    node stub.mjs
    
    stub listening on http://127.0.0.1:9691
  2. Step 2.

    Run the suite against it.

    npx vitest run
    
    FAIL  scratch/stub-throttle-969x/stub.test.mjs > the user name is exactly Ada
    AssertionError: expected '{"id":1,"name":"Ada"}' to be 'Ada' // Object.is equality
    
    Expected: "Ada"
    Received: "{"id":1,"name":"Ada"}"
    
    Test Files  1 failed (1)
        Tests  1 failed | 1 passed (2)

    The loose test is green. The strict one shows why: user.name holds the whole response body, not a field out of it.

  3. Step 3.

    Print the header the stub sent and the branch the client took.

    for (const path of ['/user', '/user-typed']) {
      const res = await fetch(new URL(path, BASE));
      console.log(path, 'content-type:', res.headers.get('content-type'));
      console.log(path, 'client:', JSON.stringify(await getUser(BASE, path)));
    }
    
    /user content-type: null
    /user client: {"branch":"text","name":"{\"id\":1,\"name\":\"Ada\"}"}
    /user-typed content-type: application/json
    /user-typed client: {"branch":"json","id":1,"name":"Ada"}

    One header decides which half of the client runs. A stub that omits it tests the half production never uses.

  4. Step 4.

    Ask for the 204 the stub writes a body into.

    const res = await fetch('http://127.0.0.1:9691/empty');
    console.log('status', res.status, 'content-type', res.headers.get('content-type'), 'content-length', res.headers.get('content-length'));
    const text = await res.text();
    console.log('body length', text.length, JSON.stringify(text));
    try { await (await fetch('http://127.0.0.1:9691/empty')).json(); }
    catch (e) { console.log('json() threw:', e.constructor.name + ':', e.message); }
    
    status 204 content-type application/json content-length null
    body length 0 ""
    json() threw: SyntaxError: Unexpected end of JSON input

    Two facts in three lines. Node's own server refuses to send the body on a 204, so this stub cannot reproduce a service that wrongly sends one. And the empty body alone breaks a client that calls res.json() without checking the status first.

  5. Step 5.

    Compare a route the stub answers directly with one it redirects.

    for (const path of ['/user-typed', '/old']) {
      const before = (await (await fetch(BASE + '/__hits')).json()).length;
      const res = await fetch(BASE + path);
      const after = await (await fetch(BASE + '/__hits')).json();
      console.log(path, '-> status', res.status, 'redirected', res.redirected, 'final url', res.url);
      console.log('   stub saw:', after.slice(before).join(' , '));
    }
    
    /user-typed -> status 200 redirected false final url http://127.0.0.1:9691/user-typed
     stub saw: GET /user-typed
    /old -> status 200 redirected true final url http://127.0.0.1:9691/user-typed
     stub saw: GET /old , GET /user-typed

    Both calls hand the caller a 200. Only the redirected one makes two requests, changes res.url, and exposes whatever the client attaches per request.

  6. Step 6.

    Make the stub fail three different ways.

    for (const path of ['/fail', '/slow', '/reset']) {
      const t = Date.now();
      try { console.log(path, 'resolved', JSON.stringify(await getUser(BASE, path)), Date.now() - t, 'ms'); }
      catch (e) { console.log(path, 'threw', e.constructor.name + ':', e.message,
        e.cause ? '| cause ' + (e.cause.code ?? e.cause.message) : '', '|', Date.now() - t, 'ms'); }
    }
    
    /fail threw Error: HTTP 500  | 51 ms
    /slow threw DOMException: The operation was aborted due to timeout  | 2025 ms
    /reset threw TypeError: fetch failed | cause UND_ERR_SOCKET | 7 ms

    Three failures, three exception types. A suite that only asserts rejects.toThrow() treats them as one, and the retry rule you meant to test never runs.

  7. Step 7.

    Assert that the stub served the request, not something else. This run has a global fetch replacement left behind by another file.

    npx vitest run --config vitest.hits.config.mjs
    
    FAIL  scratch/stub-throttle-969x/hits.test.mjs > the stub served that request
    AssertionError: expected +0 to be 1 // Object.is equality
    
    - Expected
    + Received
    
    - 1
    + 0
    
    Test Files  1 failed (1)
        Tests  1 failed | 1 passed (2)

    The first test passed against a response no server produced. The request counter is what caught it.

  8. Step 8.

    Remove the leftover global and run the same file again.

    NO_LEFTOVER=1 npx vitest run --config vitest.hits.config.mjs
    
     Test Files  1 passed (1)
        Tests  2 passed (2)

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | branch: 'text' against your stub | The stub sent no content-type | Set the header the real service sets, then rerun. The strict assertion should pass. | | An assertion passes on a string holding the whole body | The client never parsed the response | Assert on a field, not with toContain over a body. | | redirected false where the service redirects | The stub answers the old path directly | Give the stub the 301 and a location, so the client's redirect handling runs. | | Zero requests in /__hits after a green run | Nothing reached the stub | A global mock, a cached response or a wrong base URL. Step 7 is the check. | | TypeError: fetch failed with no status | The connection broke before a response arrived | Match on err.cause.code, not on err.message. |

Common mistakes

Sign: The suite is green and the stub's request log is empty.Cause: A global fetch replacement from another test file, a base URL that fell back to a default, or a response served from a cache. None of them fails a test, because the assertions still see the shape they expect. Count requests on the stub and assert the count, as in step 7.
Sign: A stub written in Node cannot reproduce a 204 that carries a body.Cause: Node's http server drops the body for a 204 whatever you pass to res.end(). The capture in step 4 shows content-length absent and zero bytes read, with the content-type header still sent. Testing a client against a malformed 204 needs a raw socket write, not a stub built on node:http.
Sign: A test asserts err.code is ECONNRESET and the assertion never matches.Cause: Node's fetch does not surface the socket error code at the top level. A destroyed socket arrives as TypeError: fetch failed with err.cause.code set to UND_ERR_SOCKET, which is an undici code and not a POSIX one. Assert on the cause.
Sign: Every stub route answers 200 and the error tests still pass.Cause: They pass because they assert that a promise rejects, and a 500, a timeout and a reset all reject. Step 6 produced Error, DOMException and TypeError from one client. Assert the type, and give the stub a route for each.

What to check next

FAQ

What is the difference between a mock and a stub?

A stub returns canned answers and is never asserted on. A mock records calls and fails the test when they do not match. The server on this page is both: /user is a stub, and /__hits turns it into a mock for the one assertion in step 7.

Do I need msw for this?

No. msw intercepts requests inside the process under test, which is convenient and skips the socket. A separate server on a port also exercises the client's URL building, redirect handling and connection errors, none of which an interceptor reaches.

Should the stub validate the request?

Record first, validate second. A request log costs three lines and catches the failure in step 7. Validating bodies and headers earns its place once a test depends on the client sending them.

How do I stop the stub after the run?

Call server.close() from a global teardown, or stop the process you started by its PID. On Windows, netstat -ano | grep 9691 gives the PID and Stop-Process -Id <pid> -Force ends it. Confirm the port has no listener afterwards.

Verified

Verified by Maks Vernynode 22.23.2vitest 5.0.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny