Keep the request logic separate from the network
A fetch call has more than one way to fail. A successful request may return the data you expect. A server may return an HTTP error status, in which case fetch still resolves with a Response. Or the request itself may fail, in which case fetch rejects. Tests should distinguish these outcomes because application code often handles them differently.
The example below runs in Node.js 20 or later, using its global fetch and Response implementations and its built-in test runner. Create a file named profile.mjs with this function. Passing fetchImpl as an optional argument lets tests supply a stand-in without changing global state or contacting a server: export async function loadProfile(id, fetchImpl = fetch) { const response = await fetchImpl(`/api/profiles/${encodeURIComponent(id)}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json(); }
In an application, calling loadProfile('42') uses the normal fetch function. In a test, the second argument can be an async function that returns a chosen Response or throws an error. This is dependency injection in a small, practical form: the request logic stays the same while the network behavior is under the test’s control.
Test a successful response
Create article.test.mjs beside profile.mjs. Import the test runner, assertions, and the function under test. A fake fetch function receives the requested URL and returns a Response containing JSON. Checking the URL as well as the result catches mistakes in both request construction and response handling: import test from 'node:test'; import assert from 'node:assert/strict'; import { loadProfile } from './profile.mjs'; test('returns a profile from a successful response', async () => { const fakeFetch = async (url) => { assert.equal(url, '/api/profiles/42'); return new Response(JSON.stringify({ name: 'Ada' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const profile = await loadProfile('42', fakeFetch); assert.deepEqual(profile, { name: 'Ada' }); });
Run node --test article.test.mjs from the directory containing both files. This test exercises the real branching and JSON parsing in loadProfile, but it does not depend on a live API. The Content-Type header makes the mock resemble a typical JSON response; response.json() itself parses the body whether or not that header is present.
Test an HTTP error without confusing it with a network failure
For an HTTP error, the mock should return a Response with an error status, not throw. The Response constructor sets ok to false for that status. Add this test to article.test.mjs: test('throws the status for an HTTP error', async () => { const fakeFetch = async () => new Response('Unavailable', { status: 503 }); await assert.rejects( () => loadProfile('42', fakeFetch), /HTTP 503/ ); });
This verifies that loadProfile checks response.ok before trying to parse the body as JSON. The text body is deliberate: if the function incorrectly called response.json() first, the test would encounter a parsing error instead of the intended HTTP error. HTTP 404 or 500 responses follow the same fetch behavior: receiving an HTTP response does not, by itself, make fetch reject.
Test a rejected request
A network failure is different because there is no Response to inspect. Model that by making fakeFetch reject. The current loadProfile function does not transform this error, so the test checks that it reaches the caller: test('passes through a request failure', async () => { const fakeFetch = async () => { throw new TypeError('connection lost'); }; await assert.rejects( () => loadProfile('42', fakeFetch), /connection lost/ ); });
If your interface needs different messages for HTTP errors and failed requests, put that policy in a separate function or update loadProfile deliberately, then adjust the assertions to match. A useful next test is malformed JSON in an otherwise successful response. Keeping that case separate makes it clear whether a failure came from the request, the HTTP status, or the response body.
Keep mocks focused on observable behavior
These tests supply only the behavior loadProfile uses: a function accepting a URL and either returning a Response or rejecting. That is usually easier to maintain than replacing global fetch for every test. It also allows tests to run independently without restoring a shared mock after each case.
Mock-based tests cannot prove that a real endpoint exists or returns the expected shape. Their job here is narrower: confirm what this function requests and how it handles each kind of outcome. Pair them with an appropriate integration test when you need confidence in the connection between the application and an actual service.



