Network
Observe traffic, wait for responses, route requests, mock API states, and understand the current API request limitation.
Network tools help when the page depends on something outside the DOM: an API response, a slow service, a third-party script, an auth cookie, or an error path that is hard to produce through the normal UI.
The safe default is still simple: test the user-visible behavior first. Use network tools when they give you control, evidence, or a reachable edge case that the UI alone cannot provide cleanly.
Mental model: observe, wait, route, or call the API
There are four different jobs:
| Job | Tool | Use it when |
|---|---|---|
| Observe traffic | request/response events | you need evidence without changing behavior |
| Wait for one response | waitForResponse() |
one backend answer is part of the contract |
| Control browser traffic | route() |
you need to mock, abort, modify, or inspect page requests |
| Call an API directly | request() |
you need setup or backend verification without rendering another page |
Do not blur those jobs. A route changes what the page receives. An event observes what happened. An API request bypasses the page entirely.
Register before the request happens
Routes and event handlers only see future traffic. Register them before goto(), before the click that sends the request, or before the script that triggers the request.
use Playwright\Network\RequestInterface;
$page->events()->onRequest(static function (RequestInterface $request): void {
if ('xhr' !== $request->resourceType()) {
return;
}
printf('> %s %s'.PHP_EOL, $request->method(), $request->url());
});
$page->goto('https://app.example.test/orders');
Expected result: only XHR requests made after the handler registration are printed.
If a route or event "sometimes" misses a request, first check registration order.
This rule also applies to pages created later. A page-level route affects one page. A context-level route affects pages in that context, including pages opened after the route is registered. If a popup or redirect creates another page, context routing is often the safer boundary.
Observe traffic without changing behavior
Request and response events are diagnostic. They should collect evidence or produce filtered logs.
use Playwright\Network\ResponseInterface;
$page->events()->onResponse(static function (ResponseInterface $response): void {
if (!str_contains($response->url(), '/api/orders')) {
return;
}
printf('< %d %s'.PHP_EOL, $response->status(), $response->url());
});
Keep logs narrow. A full network dump can expose secrets and make the important failure harder to see.
Use events when you want to know what happened. Use routes when you need to change what happens.
Observation is the right first step when a failure is unclear. Add a filtered request or response log, reproduce once, then decide whether the test needs a stronger wait, a route, or only better failure evidence.
Wait for one response when it is part of the contract
Use waitForResponse() when a specific backend answer is the readiness signal.
use function Playwright\Testing\expect;
$page->goto('https://app.example.test/reports');
$response = $page->waitForResponse('**/api/reports/export', [
'action' => 'document.querySelector("[data-testid=export]").click()',
'timeout' => 10000,
]);
if (202 !== $response->status()) {
throw new RuntimeException('Export request was not accepted.');
}
expect($page->getByText('Export started'))->toBeVisible();
The PHP implementation currently accepts an array-only action helper for this coupling. It is useful when the response can arrive before a separate wait starts, but it should stay small and targeted. Prefer explicit locator actions when race-free coupling is not required, and still end with a page assertion.
If the response only helps debug the flow, do not make it the final proof. The user sees the page, not the HTTP exchange.
Route requests deliberately
route() intercepts page traffic. Use page-level routing when only one page needs the behavior. Use context-level routing when every page in the context should share it.
use Playwright\Network\RouteInterface;
use function Playwright\Testing\expect;
$page->route('**/api/orders', static function (RouteInterface $route): void {
$route->fulfill([
'status' => 200,
'contentType' => 'application/json',
'body' => json_encode([
['id' => 1001, 'status' => 'paid'],
['id' => 1002, 'status' => 'pending'],
], JSON_THROW_ON_ERROR),
]);
});
$page->goto('https://app.example.test/orders');
expect($page->getByRole('row'))->toHaveCount(2);
expect($page->getByText('pending'))->toBeVisible();
This makes a rare API state reachable without preparing a real backend fixture.
Mock only what the test owns. If the scenario is about empty orders, mock /api/orders. Do not mock authentication, feature flags, analytics, translations, and the orders endpoint in the same route unless the test is explicitly about that whole boundary. A narrow mock leaves more of the product real.
Abort or continue traffic
Abort requests to test fallback behavior or remove irrelevant resources:
use Playwright\Network\RouteInterface;
$page->route('**/*.{png,jpg,jpeg}', static function (RouteInterface $route): void {
$route->abort();
});
Continue requests when you need them to proceed, possibly with changes:
$page->route('**/api/**', static function (RouteInterface $route): void {
$headers = $route->request()->headers();
$headers['X-Test-Run'] = 'checkout-empty-cart';
$route->continue(['headers' => $headers]);
});
Keep patterns narrow. A broad **/* route can accidentally affect HTML, redirects, fonts, scripts, images, and API calls at once.
When a route should no longer apply, remove it with unroute() from the same page or context where it was registered.
Use API requests for setup and backend checks
request() gives you an APIRequestContext associated with the current browser context. Use it for setup, teardown, or backend verification that should not require loading another page.
$api = $context->request();
$response = $api->post('https://app.example.test/api/test/orders', [
'data' => ['product' => 'keyboard', 'quantity' => 1],
]);
if (201 !== $response->status()) {
throw new RuntimeException('Order setup failed.');
}
$order = $response->json();
$page->goto('https://app.example.test/orders/'.$order['id']);
expect($page->getByRole('heading'))->toHaveText('Order');
Use API requests to prepare the world, then use the browser for the user journey the test needs to prove.
The most common pattern is: create data by API, visit the UI that displays it, then assert the UI. That keeps setup fast without weakening the product assertion.
For destructive cleanup, prefer API calls over UI cleanup steps unless cleanup behavior is the feature under test. A test that creates an order through the UI does not need to delete it through the UI unless deletion is also the scenario.
Common mistakes
Registering routes too late. If the page already requested the endpoint, the route cannot intercept it.
Mocking too broadly. Broad patterns hide real behavior and make debugging harder.
Replacing user flows with API calls. API setup is useful. It is not proof that the UI works.
Logging secrets. Request headers, cookies, authorization values, and response bodies can leak credentials into CI logs.
Asserting only the response. If the feature is visible to users, finish with a visible page assertion.
Verification checklist
- Was the route or handler registered before traffic started?
- Is the URL pattern narrow enough?
- Does the test distinguish observing from modifying traffic?
- Does the response status or JSON prove the backend condition?
- Does the final assertion prove what the user sees?
- Are headers, cookies, and bodies filtered before logging?
Go next
- To observe one exchange without changing it, wait for a network response.
- To control a dependency, read Routing and then mock one API response.
- To use the direct request client, read its current implementation status before designing the test.
- For exact contracts, use the Route, Request, and Response reference pages.