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
- Node 22 or later. The steps use the global
fetchandAbortSignal.timeout, both built in. - vitest 5. The vitest CLI reference covers
runand--config. - A free port. Check it before you start:
netstat -ano | grep 9691on Windows,lsof -i :9691elsewhere. - The stub, saved as
stub.mjs. Every route on this page is one of its cases, and the request log at/__hitsis what step 7 asserts on.
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'));
- The client under test,
client.mjs. It branches oncontent-type, which is the point of steps 2 and 3.
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() };
}
- The suite,
stub.test.mjs, with one loose assertion and one strict one.
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');
});
- Loopback has no network in it. These runs say what the client does with a response. They say nothing about latency, TLS, proxies or a load balancer.
Steps
- Step 1.
Start the stub in its own shell.
node stub.mjsstub listening on http://127.0.0.1:9691 - Step 2.
Run the suite against it.
npx vitest runFAIL 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.nameholds the whole response body, not a field out of it. - 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.
- 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 inputTwo 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. - 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-typedBoth calls hand the caller a 200. Only the redirected one makes two requests, changes
res.url, and exposes whatever the client attaches per request. - 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 msThree 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. - Step 7.
Assert that the stub served the request, not something else. This run has a global
fetchreplacement left behind by another file.npx vitest run --config vitest.hits.config.mjsFAIL 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.
- Step 8.
Remove the leftover global and run the same file again.
NO_LEFTOVER=1 npx vitest run --config vitest.hits.config.mjsTest 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
What to check next
- How to mock fetch in vitest: the in-process alternative, for when a real port is more than the test needs.
- How to reset mocks between tests: the leftover global that made step 7 fail.
- How to check content-type of API response: reading the header off the real service, so the stub can copy it.
- How to test API timeout handling: what the client should do with the
/slowroute. - How to simulate slow network in chrome: slowing the whole connection instead of one endpoint.
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.
Related on this site
intermediate10 minpublished updated Maks Verny