Inspecting the WireMock Request Journal

A test fails with a 404 from your mock and the stub looks correct. The journal is where that argument ends: it records every request WireMock received, verbatim, and reports which stub came closest to matching and on exactly which attribute it diverged. This page covers reading it, querying it, and converting what you find into an assertion.

Context: the mock is not lying to you

When a request misses every stub, WireMock returns a 404 with a body explaining that no mapping matched. The instinct is to re-read the mapping, decide it looks right, and start changing things at random. The journal removes that step by showing what the server actually received rather than what the code appears to send.

The gap between those two is almost always something the client added on its own: an Accept header the HTTP library sets by default, a charset appended to the content type, a query parameter appended by an interceptor, a trailing slash added by a URL builder. None of these are visible at the call site, and all of them break an exact-match stub.

The gap the journal makes visible Two panels. The left shows the request as it appears at the call site: a GET to /orders with no headers of note. The right shows the same request as recorded in the journal, carrying an added trailing slash, an appended locale query parameter, and a content type with a charset suffix. Three markers point at the three additions, each labelled with the layer that introduced it. What the call site says GET /api/orders Accept: application/json — and the stub matches this exactly What the journal recorded GET /api/orders/?locale=en-GB Accept: application/json, text/plain, */* Content-Type: application/json;charset=utf-8 — and the stub matches none of it trailing slash — added by the URL builder · locale — added by a request interceptor · charset — added by the HTTP library None of the three appear in the code you were staring at, which is why re-reading the mapping never resolves it. The journal is the only place the real request exists.

Solution

1. List what was received

# Everything the mock has seen, newest first
curl -s http://localhost:8080/__admin/requests \
  | jq '.requests[] | {method: .request.method, url: .request.url, matched: .wasMatched}'

# Just the misses — usually the whole story
curl -s http://localhost:8080/__admin/requests \
  | jq '[.requests[] | select(.wasMatched == false) | .request | {method, url, headers, body}]'

The wasMatched flag is the fastest triage. A journal full of true with one false localises the problem to a single call; a journal that is entirely false usually means the base URL is wrong and nothing is reaching the intended path at all.

2. Ask which stub came closest

The near-miss endpoint is the part most people never discover, and it is the one that actually answers the question:

curl -s http://localhost:8080/__admin/requests/unmatched/near-misses \
  | jq '.nearMisses[0] | {
        stub: .stubMapping.request.urlPath,
        diff: .matchResult.distance,
        received: .request.url
      }'

WireMock scores every stub against the unmatched request and returns them ranked by distance. The top entry is the stub you meant to hit, and comparing its request block against the recorded one shows the divergence directly. A distance of 0.06 means one attribute out of many differed — look for the single field that is not identical. A distance near 1 means nothing about the two resembles each other, which points at a wrong path or method rather than a detail.

For a human-readable version, WireMock also logs the same comparison when started with --verbose:

Request was not matched
=======================
                                        | Closest stub
-----------------------------------------------------
GET                                     | GET
/api/orders/?locale=en-GB               | /api/orders          <<<<< URL does not match

The <<<<< marker names the failing attribute. That single line replaces the entire guessing phase.

3. Turn the diagnosis into an assertion

Having found it, stop it recurring. WireMock’s verification endpoint counts matching requests, so a test can assert the call was made with the shape you now know it has:

curl -s -X POST http://localhost:8080/__admin/requests/count \
  -H 'Content-Type: application/json' \
  -d '{
        "method": "GET",
        "urlPathPattern": "/api/orders/?",
        "queryParameters": { "locale": { "equalTo": "en-GB" } },
        "headers": { "Accept": { "contains": "application/json" } }
      }' | jq '.count'

Note the matchers: urlPathPattern with an optional trailing slash, contains rather than equalTo on Accept. Matching loosely on attributes the client controls and strictly on the ones your code sets is what makes a stub survive an HTTP-library upgrade.

Wired into a test, that becomes an ordinary expectation:

// tests/orders.integration.test.ts
import { expect, it } from 'vitest';

const ADMIN = process.env.WIREMOCK_ADMIN ?? 'http://localhost:8080/__admin';

async function countRequests(criteria: unknown): Promise<number> {
  const res = await fetch(`${ADMIN}/requests/count`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(criteria),
  });
  const { count } = (await res.json()) as { count: number };
  return count;
}

it('requests orders exactly once, with the locale the user selected', async () => {
  await loadOrdersPage({ locale: 'en-GB' });

  const n = await countRequests({
    method: 'GET',
    urlPathPattern: '/api/orders/?',
    queryParameters: { locale: { equalTo: 'en-GB' } },
  });

  expect(n).toBe(1);   // not 0 (never called) and not 2 (duplicate fetch)
});

Asserting toBe(1) rather than toBeGreaterThan(0) catches the duplicate-fetch bug that a React effect with a missing dependency array produces — a defect no response-level assertion will ever see.

Three endpoints, three questions Three stacked rows. The requests endpoint answers what was received and whether it matched. The near-misses endpoint answers which stub was closest and on which attribute it diverged. The count endpoint answers how many times a request of a given shape was made, which is the form that belongs in a test assertion. GET /__admin/requests every request verbatim, with a wasMatched flag "What did the app actually send?" use first — it ends the argument GET /__admin/requests/unmatched/near-misses ranked stubs with a distance score per attribute "Which stub did I mean, and why did it miss?" the endpoint most people never find POST /__admin/requests/count how many requests match a criteria document "Was it called, and exactly how many times?" the form that belongs in a test

Verification

# The journal is recording at all
curl -s http://localhost:8080/__admin/requests | jq '.requests | length'

# No unmatched requests remain after the fix
curl -s http://localhost:8080/__admin/requests \
  | jq '[.requests[] | select(.wasMatched == false)] | length'   # expect 0

The second command is worth running as the last step of any integration job. A run that passes with unmatched requests in the journal is passing by luck — some call fell through to a default and the assertion happened not to notice.

Capture it as an artefact when the job fails, so the record survives the container:

      - name: Capture the mock journal on failure
        if: failure()
        run: curl -s http://localhost:8080/__admin/requests > wiremock-journal.json
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: wiremock-journal
          path: wiremock-journal.json

Gotchas and edge cases

  • The journal is capped and silently drops the oldest entries. WireMock keeps a bounded number of requests (--max-request-journal-entries). On a long run the request you are hunting may already have been evicted. Raise the cap for the debugging run, or reset the journal immediately before the failing action so it contains only that.

  • Request bodies are recorded, including anything sensitive. Auth headers, tokens and personal data in payloads all end up in the journal, and uploading it as a CI artefact publishes them to anyone with repository access. Scrub before uploading, or restrict the artefact — the same care described in mock data privacy and anonymisation.

  • Counting requests across parallel jobs gives nonsense. One WireMock shared by several concurrent test jobs has one journal, so a count assertion sees every job’s traffic. Either give each job its own instance, or add a per-job header to every request and include it in the count criteria so the assertion only sees its own.


What to keep when a job fails Three artefacts worth capturing on failure: the full journal, the unmatched subset, and the near-miss report for each unmatched request. Together they answer what was sent, what was not served, and why — which is usually the entire investigation. The full journal every request, matched or not the complete record The unmatched subset usually the whole story one jq filter away Near-misses per unmatched request which stub was closest, and why it missed turns guessing into a one-line fix Scrub before uploading: journals contain auth headers and request bodies verbatim.

Reading the journal as a narrative

The journal is usually consulted as a lookup — find the failing request, read it, move on. Reading it in order, as a sequence, answers a different and often more useful question: what did the application actually do?

A page that was expected to make three calls and made seven has a request waterfall problem visible nowhere else. Calls arriving in an unexpected order reveal a race. The same endpoint appearing twice within milliseconds is a double-fetch. An endpoint that never appears at all means a code path did not run, which is frequently the real bug rather than anything about the response.

None of these are visible from a single entry, and none of them are visible from the rendered output. Ten seconds spent reading the journal top to bottom, before filtering it, is one of the higher-yield debugging habits available in this whole area.

← Back to Mock Lifecycle Management