Asserting Request Payloads Without Brittle Snapshots

The request your code sends is part of its contract, and it deserves assertions as careful as the response it handles. Most suites either ignore it — the mock returns a canned response regardless of what was sent — or snapshot the whole thing, which fails on every added field, reordered property and changed timestamp until people update snapshots without reading them. Neither catches the bugs that matter: a price sent in pounds instead of pence, a required field omitted when a form is partially filled, an idempotency key that changes between retries. This guide covers capturing requests in MSW v2, asserting on the parts that express behaviour with asymmetric matchers, validating shape with a schema, and structuring tests so each one checks one thing. It sits under HTTP request stubbing techniques.

Root Cause Analysis

A payload has two kinds of content. Some fields express the behaviour under test — the amount, the currency, the items, the choice the user made. Others are incidental — a generated identifier, a timestamp, a client version header, the order in which keys happen to be serialised. A good assertion pins the first kind and tolerates the second; a snapshot pins both, and so fails for reasons unrelated to behaviour.

Once a snapshot fails often enough for incidental reasons, updating it becomes reflexive, and at that point it verifies nothing — the one time it fails for a real reason, it is updated along with all the others. That is the specific failure mode that makes whole-payload snapshots worse than no assertion: they create the appearance of verification while training people to discard it.

Ignoring the request entirely fails differently. The mock returns success whatever was sent, so a code change that sends the wrong amount still receives the response the test expects and the test passes. The bug is caught — if at all — by the real server rejecting the request, or worse, accepting it.

Behavioural and incidental fields in one payload Amount, currency, items and the idempotency key express behaviour and should be asserted exactly, while generated ids, timestamps and client headers are incidental and should be matched loosely or ignored. behavioural — assert exactly amountPence: 4999 currency: "GBP" items: [{ sku, quantity }] stable idempotency key incidental — match loosely requestId: any string createdAt: any ISO date x-client-version header key order
A snapshot treats both columns alike, which is exactly why it fails for the wrong reasons.

Reproducible Setup

A small helper that captures every request a handler receives, so tests can assert on them after the code under test has run.

// test/msw/capture.ts
import { http, HttpResponse, type DefaultBodyType } from 'msw';
import { server } from './server';

type Captured = { url: URL; method: string; headers: Headers; body: unknown };

export function capture(method: 'post' | 'put' | 'patch', path: string, respond: DefaultBodyType = {}) {
  const requests: Captured[] = [];
  server.use(http[method](path, async ({ request }) => {
    const type = request.headers.get('content-type') ?? '';
    const body = type.includes('json') ? await request.clone().json()
      : type.includes('form') ? Object.fromEntries(await request.clone().formData())
      : await request.clone().text();
    requests.push({ url: new URL(request.url), method: request.method, headers: request.headers, body });
    return HttpResponse.json(respond);
  }));
  return { requests, last: () => requests.at(-1)! };
}

Implementation

Step 1 — Assert exactly on the behavioural fields, loosely on the rest. toMatchObject ignores fields you do not mention; asymmetric matchers accept any value of the right shape for the incidental ones.

// src/checkout/submit-order.test.ts
import { test, expect } from 'vitest';
import { capture } from '../../test/msw/capture';
import { submitOrder } from './submit-order';

test('sends the total in pence with the chosen currency', async () => {
  const orders = capture('post', '/api/orders', { id: 'o1' });
  await submitOrder({ basket: aBasket({ totalPounds: 49.99 }), currency: 'GBP' });

  expect(orders.last().body).toMatchObject({
    amountPence: 4999,
    currency: 'GBP',
    requestId: expect.any(String),
    createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
  });
});

Notice that the test states the conversion it cares about in its setup and its expectation — £49.99 in, 4,999 pence out — so a reader can see the rule being checked without opening the implementation. That is the property snapshots lack: a snapshot records an output, but it does not say which part of that output the test is about or why that value is correct.

Step 2 — Assert collections by content, not by position. When the order of items is not part of the behaviour, arrayContaining and objectContaining keep the test from failing on a harmless reordering.

test('includes every basket line with its quantity', async () => {
  const orders = capture('post', '/api/orders');
  await submitOrder({ basket: aBasket({ lines: [{ sku: 'A', qty: 2 }, { sku: 'B', qty: 1 }] }), currency: 'GBP' });

  expect(orders.last().body.items).toHaveLength(2);
  expect(orders.last().body.items).toEqual(expect.arrayContaining([
    expect.objectContaining({ sku: 'A', quantity: 2 }),
    expect.objectContaining({ sku: 'B', quantity: 1 }),
  ]));
});

Step 3 — Assert absence explicitly where it matters. Some bugs are fields that should not be sent — a password echoed into an analytics payload, an empty string where the API expects the field omitted.

test('omits the promo field when no code was entered', async () => {
  const orders = capture('post', '/api/orders');
  await submitOrder({ basket: aBasket(), currency: 'GBP', promo: '' });
  expect(orders.last().body).not.toHaveProperty('promoCode');
});

Step 4 — Validate the whole shape with the schema the server uses. A shared schema catches a missing required field or a wrong type without enumerating every field in the test.

import { createOrderRequest } from '@acme/api-schemas';

test('every order request satisfies the API schema', async () => {
  const orders = capture('post', '/api/orders');
  await submitOrder({ basket: aBasket(), currency: 'EUR' });
  expect(() => createOrderRequest.parse(orders.last().body)).not.toThrow();
});
Choosing the right assertion tool for each question toMatchObject with exact values pins behavioural fields, asymmetric matchers accept incidental values of the right shape, arrayContaining handles unordered collections, not.toHaveProperty checks absence, and a shared schema validates overall shape. Question Tool is the amount right? toMatchObject, exact value is there some id? expect.any(String) are all items present? arrayContaining + length is a field absent? not.toHaveProperty is the shape valid overall? the shared schema
Each question has a precise tool; a snapshot answers all of them at once and none of them well.

Step 5 — Check headers that carry behaviour. An idempotency key that must stay stable across retries, or an authorization header, is behaviour; assert it directly.

test('reuses the same idempotency key when the request is retried', async () => {
  let calls = 0;
  const keys: string[] = [];
  server.use(http.post('/api/orders', ({ request }) => {
    keys.push(request.headers.get('idempotency-key')!);
    return ++calls === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ id: 'o1' });
  }));
  await submitOrder({ basket: aBasket(), currency: 'GBP' });
  expect(new Set(keys).size).toBe(1);
  expect(keys).toHaveLength(2);
});

Step 6 — Keep one behaviour per test. A test named “sends the total in pence” that also checks items, headers and absence becomes a snapshot by another name; split it, so a failure names the behaviour that broke.

There is a middle ground worth knowing for payloads that genuinely are large and stable, such as a serialised report definition: normalise the incidental fields first — replace identifiers and timestamps with placeholders — and then compare against an inline expected object. The comparison is exact where it matters and indifferent where it should be, and because the expected value sits in the test file, a reviewer reads it rather than approving a regenerated snapshot file they never open.

Whichever approach a test uses, the question to ask of each assertion is the same: if this line fails, will the message tell a reader what behaviour broke? Focused assertions answer yes by construction, because each names a field and a rule. A whole-payload comparison answers with a diff of everything, and the reader is left to work out which difference matters.

Verification

Confirm the assertions catch the bug they were written for. Change the client to send pounds instead of pence and run the suite — exactly the amount test should fail, with a diff naming the field.

npx vitest run src/checkout/submit-order.test.ts
# FAIL sends the total in pence with the chosen currency
#   - "amountPence": 4999
#   + "amountPence": 49.99

Then confirm incidental changes do not break anything: add a new optional field to the payload and change the request identifier format. No test should fail, which is the property a snapshot cannot offer.

How each approach reacts to real and incidental changes A whole-payload snapshot fails on both a real bug and an incidental change, so people update it reflexively; focused assertions fail only on the real bug and stay green through incidental changes. whole snapshot real bug: fails new optional field: fails so both get updated unread focused assertions real bug: fails, names the field new optional field: passes so a failure means something
An assertion is only valuable if its failures are rare enough to be read.

Troubleshooting

Symptom: the captured body is empty. Diagnosis: the handler read the body once and something else read it again, or the content type was not recognised. Fix: clone the request before reading, as the helper does, and check the content-type the client actually sends.

Symptom: toMatchObject passes although a nested value is wrong. Diagnosis: the nested object was matched with expect.objectContaining or expect.any(Object), which accepts anything. Fix: use exact nested values for behavioural fields and reserve asymmetric matchers for genuinely incidental ones.

Symptom: the schema check passes for an obviously wrong payload. Diagnosis: the schema is permissive — optional fields, passthrough, loose number types. Fix: tighten the shared schema, which also tightens the server; a schema that accepts wrong requests is a finding in itself.

Symptom: tests are coupled to the capture helper’s internals. Diagnosis: assertions index into requests[0] everywhere. Fix: expose last() and small query helpers, so tests read as “the last order request” rather than as array bookkeeping.

FAQ

Are snapshots ever right for requests?

For large, stable, generated payloads — a serialised document, a complex query — an inline snapshot of a normalised form can be reasonable, provided incidental fields are replaced first. For ordinary API calls, focused assertions are better on every axis.

Should the mock return different responses for different payloads?

Sometimes — a handler that returns a validation error when a required field is missing makes the client’s error handling testable. But keep request assertions and response scripting separate; a handler that validates and also returns success hides whether the client sent the right thing.

How does this relate to contract testing?

Payload assertions verify your client sends what you intend; contract tests verify that what you intend is what the provider accepts. Both are needed, and a shared schema, as in Step 4, is the bridge between them — see contract testing.

What about query strings and URL parameters?

Assert them from the captured URL’s searchParams, by name, rather than comparing the full URL string, which fails on parameter order. Treat pagination cursors and cache-busting parameters as incidental unless the test is about them.