How-to › Test and mock integrations

How to control which stub wins when mock rules overlap#

Make stub precedence explicit in WireMock, MSW and nock, and fail a test when a stub never answers, so a catch-all cannot hide the specific case you just added.

Audience
API consumer
Level
advanced
Topic
Mock an API
Verified

Read first: Stub outbound HTTP calls in Node tests with nock

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
MSWHandlers shared between browser tests, Node tests and local developmentArray order is the precedence, so a merge that reorders two handlers changes behavior without a conflictStubs live as files edited by people who never read the code
nockNode suites where each test declares its own one-shot stubsDeclaration order decides, and one .persist() on a broad interceptor shadows everything after itStubs are shared across many tests or services
WireMockStubs kept as JSON files, served to several services or languagesA Java process to run, and mappings nobody reviews next to the code they serveEvery 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.

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

  1. Mock Service Worker runs no service worker in Node, and setupServer starts 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

  2. 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

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.