skip to content

In Playwright, how do you capture a page's API response and assert on its body?

level: juniorimportance: must knowfreq 74%

answer

  1. Traffic is an event stream
  2. Subscribe before the click
  3. Promise first, action, then await
  4. Match by URL glob, RegExp or predicate
  5. Status, headers and parsed JSON body

basics

~10 s

Create the wait first with page.waitForResponse, matching by URL glob, RegExp or predicate, then trigger the action and await the promise. The resolved Response exposes status(), headers() and json() for assertions.

solid answer

~40 s

`page.waitForResponse(urlOrPredicate)` resolves with the `Response` object for the first matching response the page receives after the call. The idiomatic shape is three lines: build the promise, perform the action, then await the promise. On the resolved `Response`, `status()` returns the HTTP status code, `ok()` is true for 2xx, `json()` parses the body, `text()` returns it raw, `headers()` gives lower-cased headers (use `allHeaders()` for `set-cookie` and other security headers) and `request()` walks back to the outgoing `Request`. On a weather dashboard that means a test can assert the forecast call came back `200` with seven daily entries in its payload. The wait rejects after 30 seconds by default; pass `{ timeout: ms }` to change that, or `0` to disable it.

code

typescript · 14 lines
typescript
import { expect, test } from '@playwright/test';

test('forecast panel loads seven days', async ({ page }) => {
  await page.goto('/dashboard');

  const forecastResponse = page.waitForResponse('**/api/forecast**');
  await page.getByRole('button', { name: 'Refresh forecast' }).click();

  const response = await forecastResponse;
  expect(response.status()).toBe(200);

  const body = await response.json();
  expect(body.daily).toHaveLength(7);
});

go deeper

for a junior

Learn the three-line shape by heart: assign the waitForResponse promise, do the action, then await it. Remember status() is a plain number and json() must be awaited.

for a middle

Explain why the promise is created first. The wait subscribes to page events at call time, so a response that arrived earlier is invisible and the promise can only time out.

for a senior

Say which matcher you would pick for a dashboard calling several endpoints, and how you keep the failure readable: a narrow predicate fails with a useful message instead of a bare timeout.

for a principal

Own the convention for how much of a payload a browser test asserts on. Snapshotting a whole third-party response couples the suite to a provider's schema; a status plus one field survives their changes.

## Two ways to watch a page's network Every Playwright `Page` reports the traffic the browser makes on its behalf, and it offers two different shapes for reading it. The **push** side is the event emitter: `page.on('request')`, `page.on('response')`, `page.on('requestfinished')` and `page.on('requestfailed')` hand you every exchange as it happens, which suits collecting or logging everything a page did. The **pull** side is `page.waitForRequest(urlOrPredicate)` and `page.waitForResponse(urlOrPredicate)`, which extract one specific exchange from that stream and return it as a promise. When a test needs to assert on what a server actually returned, the pull side is the right tool: it yields a single `Response` object you can interrogate. ## The three-line shape `waitForResponse` subscribes at the moment it is called and resolves on the first matching response received **after** that moment. So every use follows the same order: 1. **Create** the promise for the response you expect — assign it, do not await it yet. 2. **Trigger** the action that causes the request: a click, a `page.goto()`, a keypress. 3. **Await** the promise, then assert on the resolved `Response`. Awaiting the action first is the classic bug. On a weather dashboard whose forecast endpoint answers in 20 ms, the response can arrive while the click is still resolving; the listener registers too late and the wait hangs until it times out — intermittently, because a slower machine hides the race. ## What the resolved Response gives you | Call | Returns | | --- | --- | | `response.status()` | the numeric HTTP status code | | `response.ok()` | `true` for a status in the 200-299 range | | `response.json()` | promise of the body parsed as JSON | | `response.text()` / `response.body()` | promise of the body as a string or a buffer | | `response.headers()` | lower-cased headers, without cookie and other security headers | | `response.allHeaders()` | promise of the complete header set, `set-cookie` included | | `response.url()` | the URL this response came from | | `response.request()` | the `Request` object that produced it | Two of these surprise people. The body accessors are **asynchronous**, because the `response` event fires as soon as status and headers arrive while the body may still be downloading — that is also why `requestfinished` fires later. And `response.headers()` deliberately omits security-related headers, so a test that needs `set-cookie` must `await response.allHeaders()`. ## Choosing a matcher - a **glob string** such as `'**/api/forecast**'` — the shortest form, matched against the whole URL; - a **RegExp**, which matches anywhere in the URL and is convenient when an id is embedded in the path; - a **predicate** `(response) => boolean`, the only form that can look past the URL at the status, the method, or the `Request` behind the response. Prefer the narrowest matcher that still describes the call you mean: the wait resolves on the **first** match and then stops listening, so a loose glob on a page that fires several similar calls can hand you the wrong payload. ## Timeouts, failures and scope - The wait rejects after **30 seconds** by default. Pass `{ timeout: 10_000 }` to shorten it, `0` to disable it, or move the page-wide default with `page.setDefaultTimeout()`. - A timed-out wait fails the test with the matcher printed in the message. In practice that usually means the matcher never matched, not that the app never called. - Network events are scoped to one `Page`. A popup is its own `Page` with its own events, and calls issued through Playwright's `request` fixture never travel through the browser, so no page event fires for them. - Put the assertion **after** the await, not inside a listener. An expectation written in a callback that never runs passes silently. ## Navigation responses `page.goto()` already returns the main-resource `Response`, so a test that only cares about the document does not need a separate wait: ```ts const response = await page.goto('/dashboard'); expect(response?.status()).toBe(200); ``` Sub-resource calls fired by scripts during that load still need their own `waitForResponse`, created before the `goto`.

  • How would you assert on a response that arrives during page.goto rather than after a click?
    Same shape, different trigger: create the `waitForResponse` promise before calling `page.goto`, then await both. `page.goto` itself resolves with the main-resource `Response`, so the document status needs no extra wait; a forecast call fired by a script during load does.
  • Why is response.json() a promise when the response event has already fired?
    The `response` event fires as soon as status and headers are received, while the body may still be streaming. `json()`, `text()` and `body()` await that download, which is also why `requestfinished` arrives after `response` for the same request.

saying these in an interview costs you the question

  • Clicking first and only then waiting for the response
  • Thinking response.json() returns the body synchronously
  • Believing response.headers() includes set-cookie
  • Assuming the wait can see requests made before it was called
  • Treating any resolved response as a successful one without checking status