You add a stub that answers 404 for one invoice id, but the test still receives a paid invoice. The cause is a broader stub, registered earlier for every invoice id, which answers first. The 404 branch of your client never runs, and the test that was meant to cover it either fails on a confusing body or passes because it only checked the general case.
What you get
You will end up with stubs whose precedence is written down rather than implied by file order, and a guard that fails a test when a stub never answered anything. This is for you if more than one stub in your suite could match the same request.
Short answer
Make precedence explicit, then test it. WireMock takes a priority field, where 1 beats the
default of 5. MSW answers with the first matching handler in array order, and server.use()
prepends. nock consumes one-shot interceptors in declaration order. Assert the specific and the
general request in one test, and fail it when a stub never matched: listHandlers() in MSW,
pendingMocks() in nock.
You will need
Node 22.12 or later, which msw 3 declares as its floor, and a suite that already stubs HTTP.
Verified 2026-10-15 against Node 22.22.0, msw 3.0.3 and nock 15.0.1. WireMock 3.13.2 ran in
this container under Java 21, from the jar the wiremock npm package bundles, and is not re-run
by the page’s checks. If nock is new to you, start with
stub outbound HTTP calls in Node tests with nock.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| MSW | Handlers shared between browser tests, Node tests and local development | Array order is the precedence, so a merge that reorders two handlers changes behavior without a conflict | Stubs live as files edited by people who never read the code |
| nock | Node suites where each test declares its own one-shot stubs | Declaration order decides, and one .persist() on a broad interceptor shadows everything after it | Stubs are shared across many tests or services |
| WireMock | Stubs kept as JSON files, served to several services or languages | A Java process to run, and mappings nobody reviews next to the code they serve | Every caller is one Node process you control |
WireMock is the only one of the three that states precedence in the stub itself, so two files can arrive in any order and still resolve the same way. The cost is a separate server and a second language in the test setup. MSW and nock are in-process and need no server, but both take precedence from position, which nobody reads when resolving a merge.
See which stub answers
Run each ordering against the same client and print who answered. The general MSW handler
matches /v1/invoices/:id, and the specific one matches /v1/invoices/in_missing.
node msw-demo.mjs 2>&1 | head -12
general handler first in the array
getInvoice(in_missing) {"id":"in_missing","status":"paid"}
getInvoice(in_1) {"id":"in_1","status":"paid"}
never matched: GET https://api.example.com/v1/invoices/in_missing
specific handler first in the array
getInvoice(in_missing) null (the 404 stub)
getInvoice(in_1) {"id":"in_1","status":"paid"}
never matched: (none)
general in setupServer, specific via server.use()
getInvoice(in_missing) null (the 404 stub)
getInvoice(in_1) {"id":"in_1","status":"paid"}
never matched: (none)
MSW checks handlers top to bottom and the first match answers. server.use() puts its handlers
in front of the list,1 which is why an override inside one test wins over the shared setup. A
resolver that returns nothing lets the
next matching handler answer instead.
nock reads its interceptors in declaration order and removes each one as it is used:
node nock-demo.mjs | head -8
general interceptor declared first, both one-shot
1st getInvoice(in_missing) {"status":"paid"}
2nd getInvoice(in_missing) null (the 404 stub)
pending: []
general interceptor declared first with .persist()
1st getInvoice(in_missing) {"status":"paid"}
2nd getInvoice(in_missing) {"status":"paid"}
pending: ["GET https://api.example.com:443/v1/invoices/in_missing"]
The one-shot case is the dangerous one. Both interceptors were used, so pendingMocks() is empty,
yet the first request got the wrong answer. The order of calls decided the result, not the
specificity of the stubs.
Give WireMock an explicit priority
WireMock picks the stub added last when priorities are equal. A priority field
overrides that, and the lowest number wins. Put the specific case at 1 and the catch-all
well below the default.
{
"name": "in_missing does not exist",
"priority": 1,
"request": {
"method": "GET",
"urlPath": "/v1/invoices/in_missing",
"headers": {
"x-api-version": { "equalTo": "2024-06-01" }
}
},
"response": {
"status": 404,
"jsonBody": { "error": "no such invoice" }
}
}
{
"name": "any invoice is a paid invoice",
"priority": 10,
"request": {
"method": "GET",
"urlPathPattern": "/v1/invoices/[^/]+"
},
"response": {
"status": 200,
"jsonBody": { "id": "{{request.path.[2]}}", "status": "paid" },
"transformers": ["response-template"]
}
}
Start it with the mappings directory as its root:
java -jar wiremock-standalone-3.13.2.jar --port 8080 --root-dir wiremock
Run in this container, in_missing returned the 404 and in_1 the paid invoice. Loading the
same two stubs without priorities through the /__admin/mappings endpoint, catch-all last,
made the catch-all answer both. The priority scale runs from 1 down, with 5
as the default.2
Fail the test when a stub never answers
A shadowed stub is a stub that never matched, so assert that every stub was used. MSW keeps a flag on each handler, and nock lists what is still waiting.
export function unusedHandlers(server) {
return server.listHandlers().filter((h) => h.kind !== 'websocket').map((h) => {
if (typeof h.isUsed !== 'boolean') {
throw new Error(`cannot tell whether ${h.info?.header ?? 'a handler'} was used; check the msw version`)
}
return h
}).filter((h) => !h.isUsed).map((h) => h.info.header)
}
isUsed and info.header are public properties of the handler object in msw 3.0.3, but the
listHandlers() reference does not describe
them. The guard throws rather than returning an empty list if they disappear, so an upgrade
cannot turn it into a check that always passes. It skips ws.link() handlers, which have no
isUsed flag, so a suite that stubs a WebSocket beside its HTTP calls still gets an answer
instead of the version error.
Then assert both requests in the same test. Testing only the general request passes under either ordering, which is how the shadowing gets merged in the first place.
test('the specific and the general request, in one test', async () => {
const { anyInvoice, missingInvoice } = invoiceHandlers()
const server = start(missingInvoice, anyInvoice)
try {
assert.equal(await getInvoice('in_missing'), null)
assert.deepEqual(await getInvoice('in_1'), { id: 'in_1', status: 'paid' })
assert.deepEqual(unusedHandlers(server), [], 'every handler answered something')
} finally {
server.close()
}
})
Check it worked
Run both suites. Each file runs in its own process, which keeps msw and nock from intercepting
the same fetch.
node --test msw.test.mjs nock.test.mjs | tail -8
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 388.314206
# fail 0 covers the deliberate failures too. One test builds the merged order, general first,
and asserts that the guard names GET https://api.example.com/v1/invoices/in_missing. Another
hands the guard a handler with no isUsed flag and expects it to throw, and a third adds a
WebSocket handler to the list and expects the same report as before.
When it goes wrong
A stub stops matching because the request carries a query parameter or header it did not
declare. The three tools disagree on whether that is a miss at all. nock treats an undeclared
query string as a path mismatch, and its no match event names the reason:
node nock-demo.mjs | sed -n 9,19p
a query parameter the interceptor did not declare
getInvoice(in_1, expand) threw: GET /v1/invoices/in_1?expand=customer failed with 501
response: 501 ERR_NOCK_NO_MATCH
url in the message: https://api.example.com/v1/invoices/in_1
closest: GET /v1/invoices/in_1
reasons: ["Path mismatch: expected /v1/invoices/in_1, got /v1/invoices/in_1?expand=customer"]
a header that differs, reqheaders against matchHeader
getInvoice(in_2) threw: GET /v1/invoices/in_2 failed with 501
reqheaders reasons: ["Request headers don't match"]
getInvoice(in_2) threw: GET /v1/invoices/in_2 failed with 501
matchHeader reasons: ["Header mismatch: expected x-api-version to match 2023-10-16, got 2024-06-01"]
Three details in that output change how you debug. nock 15.0.1 answers a miss on a mocked host
with a 501 response, a change made in
pull request 2868 for the 15 betas. nock 14.0.17, run
in this container for comparison, rejected the fetch instead. A client that retries 5xx now
retries a missing stub.3 The error message prints the URL without its query string, which is the part
that failed. The no match event keeps it,
so read the reasons there. Declare the parameter with
.query(), and prefer
matchHeader over reqheaders, because only matchHeader names the header that failed.
MSW ignores the query string when matching, so the same request matches /v1/invoices/:id.
WireMock splits the difference: urlPath ignores the query, and url requires it, as its
request matching reference shows. In the local
WireMock run, a stub declared with url answered the query string with a 404. Its body was a
“Request was not matched” table, the closest stub on the left and <<<<< URL does not match
against the line.
A catch-all hides every near miss. In the same WireMock run, a request for in_missing with a
different x-api-version failed the specific stub, so the priority 10 catch-all answered 200
and WireMock had no miss to report. In MSW or nock the same shape leaves the specific stub
unused, which is the case the unused-stub guard exists to fail.
MSW 3 renamed onUnhandledRequest to onUnhandledFrame, and the
migration guide lists the change. Plain
JavaScript passing the old name gets the default, warn, and the request leaves the process:
node msw-demo.mjs 2>&1 | tail -8
unmatched request, MSW 2 option name onUnhandledRequest: error
[MSW] Warning: intercepted a request without a matching request handler:
• GET http://127.0.0.1:45117/v1/invoice/in_1
If you still wish to intercept this unhandled request, please create a request handler for it.
Read more: https://mswjs.io/docs/http/intercepting-requests
200 answered by the real server
When not to do this
Do not reach for priorities to make a catch-all safe. A catch-all that answers 200 for anything turns every typo into a passing test. Prefer a catch-all that answers 501 with the request in the body, so a miss is loud.
Do not run the unused-stub guard over a shared handler list in every test. A suite-wide list has handlers that one test never needs, and the guard reports them all. Run it over the handlers a test added, or once at the end of the file.
Do not move stubs into WireMock to get a priority field. A Java server in the loop costs a
startup and a second language for every developer. For a single Node process, order plus the
guard does the same job.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, msw 3.0.3 and nock 15.0.1. Every output block is what the command before it printed. WireMock 3.13.2 ran in this container under Java 21, and what it returned is described in prose rather than shown as captured output.
Footnotes
-
Mock Service Worker runs no service worker in Node, and
setupServerstarts no server. Its reference page says so: despite the word “server” in the name, it does not establish any servers and operates in the thread of your process. The name comes from the browser, where the library intercepts requests through the Service Worker API. ↩︎ Back to text -
The lowest priority is Java’s
Integer.MAX_VALUE, 2,147,483,647, according to the same stubbing page. With the default set at 5, four levels outrank the default and a little over two billion rank below it. ↩︎ Back to text -
RFC 9110 defines 501 as the answer of a server that does not support what the request requires, which is an accurate description of a stub nobody wrote. The same section makes a 501 heuristically cacheable unless something says otherwise, a property a test double rarely has occasion to use. ↩︎ Back to text