PRACTICAL GUIDE / Playwright API response server address testing
Prove which server answered a Playwright API request
Use APIResponse.serverAddr to catch wrong-host calls, inspect redirects, validate private network ranges, and avoid brittle assertions on cloud IP addresses.
In this guide6 sections
- Know what the address proves and what it does not
- Pin a process-local stub without accepting an impostor
- Validate an owned network range instead of one cloud address
- Make redirects visible before blaming DNS
- Diagnose null, wrong, and unstable results separately
- Add the check where routing is part of the requirement
What you will learn
- Know what the address proves and what it does not
- Pin a process-local stub without accepting an impostor
- Validate an owned network range instead of one cloud address
- Make redirects visible before blaming DNS
The health check returns 200, but the payload came from a developer stub on the CI runner instead of the staging cluster. Status and schema assertions both pass because the stub was copied from staging last week. The missing fact is the network peer that actually answered.
Playwright 1.61 added serverAddr() to APIResponse, the response type returned by APIRequestContext methods such as request.get(). It reports an IP address and port when that transport information is available. Used carefully, it catches wrong DNS, accidental localhost calls, redirect surprises, and environment routing mistakes. Used as a universal exact-IP assertion, it creates a flaky inventory of load balancer addresses.
Know what the address proves and what it does not
An API request starts with a URL, but several layers stand between that string and an application process. DNS can return multiple IPv4 or IPv6 addresses. A proxy can become the immediate network peer. A load balancer can terminate TLS and choose a backend. The Host header and TLS server name can select one virtual service among many on the same address.
APIResponse.serverAddr() returns either null or an object with this shape:
type ServerAddress = {
ipAddress: string;
port: number;
};The method is asynchronous:
const response = await request.get('https://api.test.example/health');
const address = await response.serverAddr();The value belongs to that response. It is not a call to DNS lookup, and it is not the URL's hostname repeated in another field. If the connection reached 10.42.18.7 on port 443, the object contains that address and port even though response.url() still contains an HTTPS hostname.
For redirected requests, Playwright documents that serverAddr() describes the last request in the redirect chain. A call to http://old.test.example that ends at https://api.test.example can therefore report the final TLS endpoint. That is usually what an end-to-end API check wants. It is wrong for a test whose purpose is to audit the first redirecting server. Such a test should disable automatic redirects for that request.
A null result is part of the API contract. It means the server address was not available in that response path. Synthetic responses, replay layers, proxies, or a platform limitation can remove socket metadata. Do not turn null into 0.0.0.0 or assume localhost. Decide whether address availability is itself a requirement for the environment being tested.
The address proves a routing fact, not application identity. One IP may front hundreds of virtual hosts. A malicious or simply wrong service can bind the expected loopback address and return the expected status. Pair the address with evidence at higher layers:
- response.url() confirms the final URL after redirects.
- response.status() and expect(response).toBeOK() establish HTTP outcome.
- response headers can carry a non-secret environment or deployment identifier.
- the parsed body proves the contract and the test data.
- securityDetails() can supply TLS protocol and certificate information for HTTPS responses.
Even that combined evidence has a boundary. A reverse proxy may be the intended endpoint, so serverAddr() cannot reveal which backend pod handled the call. Use a server-generated request ID, trace ID, or deployment header when backend identity matters. Do not infer it from the load balancer's address.
There are two response classes with similar method names. APIResponse comes from APIRequestContext, including the Playwright Test request fixture and page.request. Response comes from browser page traffic such as page.waitForResponse(). Browser-side Response has had serverAddr() longer. Check the variable's type before copying an example, especially when disposal and body methods differ around the surrounding code.
The network origin differs too. APIRequestContext requests are made by Playwright's API client from the test environment. A page navigation is made through the browser's network stack. A remote browser and a local request fixture can legitimately reach different IP addresses for the same hostname. That difference may be the exact topology the suite should detect.
Pin a process-local stub without accepting an impostor
Exact address and port assertions are appropriate when the test owns the listener. The next example creates a server on IPv4 loopback with an operating-system-assigned port, calls it through Playwright's request fixture, and proves the response came from that listener.
import { createServer } from 'node:http';
import { once } from 'node:events';
import { test, expect } from '@playwright/test';
test('API request reaches the stub created by this test', async ({
request,
}) => {
const runMarker = 'orders-stub-74f2';
const server = createServer((incoming, outgoing) => {
if (incoming.url === '/health') {
outgoing.writeHead(200, {
'content-type': 'application/json',
'x-test-server': runMarker,
});
outgoing.end(JSON.stringify({
service: 'orders-stub',
marker: runMarker,
}));
return;
}
outgoing.writeHead(404).end();
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const listener = server.address();
if (!listener || typeof listener === 'string')
throw new Error('Expected a TCP listener');
try {
const response = await request.get(
'http://127.0.0.1:' + listener.port + '/health'
);
try {
await expect(response).toBeOK();
const peer = await response.serverAddr();
expect(peer).not.toBeNull();
expect(peer?.ipAddress).toBe('127.0.0.1');
expect(peer?.port).toBe(listener.port);
expect(response.headers()['x-test-server']).toBe(runMarker);
expect(await response.json()).toEqual({
service: 'orders-stub',
marker: runMarker,
});
} finally {
await response.dispose();
}
} finally {
server.close();
await once(server, 'close');
}
});Using 127.0.0.1 in both listen() and the URL makes the address family explicit. If the example used localhost, the resolver could choose ::1 on one machine and 127.0.0.1 on another. Both are valid loopback results, but an exact IPv4 assertion would then fail for a reason unrelated to the service.
The dynamic port matters. Hard-coding 8080 invites collisions between parallel workers and local developer tools. The assertion reads the selected port from server.address(), so it still proves the peer without requiring a global reservation.
The marker closes another hole. An address and port identify a socket endpoint at one moment, but a stale process could occupy the expected port in a poorly isolated suite. Returning a per-run marker from the owned listener proves this response belongs to the current setup. A random UUID is even stronger when the test runs in a shared environment.
Disposal is included because APIResponse bodies remain in memory until the request context closes if they are not disposed. A single health payload is tiny. A reusable test pattern should still show correct ownership before someone applies it to 50 MB exports.
A failing assertion produces useful evidence:
Expected: 127.0.0.1
Received: 10.88.0.12
Final URL: http://orders:41837/health
Status: 200
x-test-server: missingThat combination says more than "wrong IP." The URL was rewritten to a container service name and the marker disappeared. Check baseURL construction, environment variables, and container DNS. If the marker were correct despite a 10.88 address, the service might be reached through a container-published interface instead of loopback.
This exact technique should stay with endpoints the test controls. Staging Kubernetes services do not promise that a particular pod, node, or ingress IP will remain fixed. An assertion copied from this example into cluster tests would mistake routine rescheduling for a defect.
Validate an owned network range instead of one cloud address
An environment boundary is often stable even when individual addresses rotate. For example, the staging API may be required to terminate inside 10.42.0.0/16. The test can validate that CIDR while leaving the last two octets free to change.
The helper below deliberately supports IPv4 only. It rejects an IPv6 response with a clear message rather than truncating or incorrectly normalizing it. If the environment supports dual stack, add a reviewed IPv6 CIDR implementation or use the network utility already approved by the project.
import { isIP } from 'node:net';
import { test, expect } from '@playwright/test';
function ipv4ToNumber(address: string): number {
if (isIP(address) !== 4)
throw new Error('Expected IPv4 address, received ' + address);
const octets = address.split('.').map(Number);
return (
((octets[0] << 24) >>> 0) +
(octets[1] << 16) +
(octets[2] << 8) +
octets[3]
) >>> 0;
}
function isInIPv4Cidr(address: string, cidr: string): boolean {
const [network, prefixText] = cidr.split('/');
const prefix = Number(prefixText);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32)
throw new Error('Invalid IPv4 prefix: ' + cidr);
const mask = prefix === 0
? 0
: (0xffffffff << (32 - prefix)) >>> 0;
return (
(ipv4ToNumber(address) & mask) >>> 0
) === (
(ipv4ToNumber(network) & mask) >>> 0
);
}
test('staging API terminates inside the staging network', async ({
request,
}, testInfo) => {
const response = await request.get('/health/ready');
try {
await expect(response).toBeOK();
const peer = await response.serverAddr();
if (!peer)
throw new Error('Server address was unavailable for staging API');
await testInfo.attach('api-network-peer', {
body: Buffer.from(JSON.stringify({
finalURL: response.url(),
ipAddress: peer.ipAddress,
port: peer.port,
status: response.status(),
}, null, 2)),
contentType: 'application/json',
});
expect(peer.port).toBe(443);
expect(isInIPv4Cidr(peer.ipAddress, '10.42.0.0/16')).toBe(true);
const body = await response.json();
expect(body.environment).toBe('staging');
expect(body.service).toBe('orders');
} finally {
await response.dispose();
}
});This test assumes baseURL and authentication are configured on the request fixture. It does not invent a custom client. The CIDR belongs in environment configuration when several deployments use different ranges, but accepting arbitrary CIDR text from CI would weaken the check. Review the allowed ranges as code or validate the variable against an approved list.
Port 443 is asserted separately. A matching private IP on port 8080 may indicate that the test bypassed the ingress and lost TLS, authentication, or rate limiting. Whether that is a bug depends on the boundary the suite claims to cover. A service-level integration project may intentionally connect directly to 8080; a public API journey should not.
The attached JSON avoids response bodies and secrets. Internal IP addresses can still be sensitive infrastructure data, so ensure the report's audience is appropriate. Public artifact storage is the wrong place for a complete network inventory. Some teams should attach only a pass/fail range label and retain the raw address in restricted logs.
CIDR membership still does not name a backend. It proves that the connection endpoint falls within an allowed network. The environment marker proves what the server claims to be. If both disagree, preserve both facts. Do not replace the received environment field in the assertion message with the expected value.
IPv6 needs deliberate treatment. A healthy dual-stack service can alternate between an A and AAAA result under different runners. Pinning the test to IPv4 may reduce production realism. Supporting both address families adds configuration and parsing complexity but removes a false source of failure. Decide from the deployment's actual contract, not from which value appeared first on a laptop.
Make redirects visible before blaming DNS
Automatic redirects can make a correct server address look wrong. This runnable example starts two local servers. The first returns a 302. The second returns the final JSON response. With normal redirect following, serverAddr() reports the second server's port.
import { createServer } from 'node:http';
import { once } from 'node:events';
import { test, expect } from '@playwright/test';
async function listen(server: ReturnType<typeof createServer>) {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Expected a TCP listener');
return address.port;
}
test('serverAddr follows the final API redirect', async ({ request }) => {
const destination = createServer((_request, response) => {
response.writeHead(200, {
'content-type': 'application/json',
});
response.end(JSON.stringify({ reached: 'destination' }));
});
const destinationPort = await listen(destination);
const redirector = createServer((_request, response) => {
response.writeHead(302, {
location:
'http://127.0.0.1:' + destinationPort + '/v2/health',
});
response.end();
});
const redirectorPort = await listen(redirector);
try {
const followed = await request.get(
'http://127.0.0.1:' + redirectorPort + '/health'
);
try {
expect(followed.status()).toBe(200);
expect(followed.url()).toBe(
'http://127.0.0.1:' + destinationPort + '/v2/health'
);
expect(await followed.serverAddr()).toEqual({
ipAddress: '127.0.0.1',
port: destinationPort,
});
} finally {
await followed.dispose();
}
const stopped = await request.get(
'http://127.0.0.1:' + redirectorPort + '/health',
{ maxRedirects: 0 }
);
try {
expect(stopped.status()).toBe(302);
expect(stopped.headers().location).toBe(
'http://127.0.0.1:' + destinationPort + '/v2/health'
);
expect(await stopped.serverAddr()).toEqual({
ipAddress: '127.0.0.1',
port: redirectorPort,
});
} finally {
await stopped.dispose();
}
} finally {
redirector.close();
destination.close();
await Promise.all([
once(redirector, 'close'),
once(destination, 'close'),
]);
}
});The two assertions answer different questions. The first proves where the completed API call ended. The second proves which endpoint issued the redirect. Neither is universally better.
A common CI surprise is an HTTP base URL that redirects to an HTTPS login host. The final address then belongs to the identity service, and the final status may still be 200 because the response is an HTML sign-in page. Checking content type and the expected final URL exposes this faster than an IP assertion alone.
Redirect limits are also evidence. An error about too many redirects is not a serverAddr failure because no completed response may be available to inspect. Capture the Location values by temporarily setting maxRedirects to 0 at each boundary or use an environment-side request tool. Do not raise the limit until the loop is understood.
Header behavior deserves review when a redirect crosses origins. Authentication carried to the wrong host is a security concern. Keep credentials scoped through the request context's supported authentication and header behavior, and avoid a generic extraHTTPHeaders object that sends an internal token to every destination. serverAddr() can show that the final peer changed; it cannot tell whether a secret leaked.
Diagnose null, wrong, and unstable results separately
Run the focused API test with a trace:
npx playwright test tests/api-network.spec.ts --project=staging --trace=on
npx playwright show-trace test-results/api-network-*/trace.zipAPI requests made by Playwright appear in trace network evidence. Select the health request and compare its requested URL, final URL, status, headers, and timing with the attached server address. The trace helps reveal redirects and duplicate calls, while the explicit attachment keeps the peer easy to find in the report.
Log the installed runtime when the method itself is missing:
npx playwright --versionA compile error saying serverAddr does not exist on APIResponse usually means the TypeScript declarations are older than 1.61. A runtime TypeError saying response.serverAddr is not a function can mean a mismatched installed package, stale worker image, or a response object supplied by another HTTP library. Printing the constructor name is less useful than checking the import, fixture type, lockfile-resolved version, and CI image.
This CI step rejects an older Playwright runtime before collecting network-peer evidence, then runs the focused smoke test with a retained failure trace:
#!/usr/bin/env bash
set -euo pipefail
playwright_version="$(npx playwright --version)"
node - "$playwright_version" <<'NODE'
const match = /(\d+)\.(\d+)\.(\d+)/.exec(process.argv[2] ?? '');
if (!match)
throw new Error('Could not parse the Playwright version');
const major = Number(match[1]);
const minor = Number(match[2]);
if (major < 1 || (major === 1 && minor < 61)) {
throw new Error(
'APIResponse.serverAddr() requires Playwright 1.61 or newer'
);
}
NODE
npx playwright test tests/api-network.spec.ts \
--project=staging \
--trace=retain-on-failureTreat null as its own category. The request may still have a valid status and body. Record final URL, response type, whether HAR replay or another interception layer was active, and whether the same call returns metadata in a plain non-replayed project. If the test's purpose is business behavior, null may be acceptable. If the test explicitly certifies network placement, fail with "server address unavailable," not "wrong server."
A consistently wrong address points to configuration. Compare:
baseURL=https://api.staging.example
finalURL=https://login.staging.example/session
peer=10.55.9.14:443
status=200
content-type=text/htmlThis is a redirect and authentication problem, not DNS for the original API host. Another pattern is:
baseURL=https://api.staging.example
finalURL=https://api.staging.example/health
peer=127.0.0.1:3128
status=200
via=qa-proxyA configured proxy explains why the immediate transport path differs from the service's published address. Review whether the returned value in that topology meets the assertion's intended meaning. The safest test may validate the proxy as the required egress point and use application evidence for the destination.
An unstable set of valid private addresses is usually load balancing or DNS rotation. Gather a small sample across runners and compare it with infrastructure ownership. Replace an accidental exact-IP check with the approved CIDR only after confirming every observed address is legitimate. Do not widen to all RFC 1918 space merely to stop failures.
Connection reuse is not itself a defect. Multiple responses can report the same peer because HTTP keep-alive keeps a connection open. Conversely, DNS and load balancing can select another peer after a connection closes. The assertion should match the deployment promise, not demand rotation or stickiness unless that behavior is explicitly under test.
The browser and request fixture can disagree. Run a paired diagnostic using page.waitForResponse() and request.get() only when the distinction matters. If a remote browser uses a grid-side proxy while APIRequestContext runs on the CI worker, different peers are expected. Label the two addresses by network origin in the report rather than comparing them without context.
Add the check where routing is part of the requirement
Start in observation mode. Attach server address, final URL, status, and an environment marker for a week without failing on the address. Review variation by runner, region, IP family, and redirect path. This prevents one engineer's successful run from becoming an incorrect global allowlist.
The observation test below records null as data, preserves the application assertion, and labels the IP family when an address is available:
import { isIP } from 'node:net';
import { expect, test } from '@playwright/test';
test('records the API network peer without pinning it yet', async ({
request,
}, testInfo) => {
const healthURL = process.env.API_HEALTH_URL;
if (!healthURL)
throw new Error('API_HEALTH_URL is required');
const response = await request.get(healthURL);
try {
await expect(response).toBeOK();
expect(response.headers()['content-type']).toContain('application/json');
const peer = await response.serverAddr();
const evidence = {
requestedURL: healthURL,
finalURL: response.url(),
status: response.status(),
peer: peer && {
ipAddress: peer.ipAddress,
port: peer.port,
addressFamily: isIP(peer.ipAddress),
},
};
await testInfo.attach('api-network-peer.json', {
body: Buffer.from(JSON.stringify(evidence, null, 2)),
contentType: 'application/json',
});
} finally {
await response.dispose();
}
});Ask the infrastructure owner for stable boundaries. A CIDR, fixed proxy endpoint, or loopback contract is reviewable. A spreadsheet of today's load balancer addresses is not. Store the approved boundary next to the test project configuration and name the environment it protects.
Then choose failure policy. A local stub test can fail on any exact mismatch. A staging smoke test can fail when the address is outside approved ranges or when metadata is null, if the runner platform guarantees it. A broad regression journey may record the address only, because making product assertions depend on socket metadata adds little value.
Keep the application assertion. A server address check should be additional evidence, never a replacement for response correctness. A peer inside 10.42.0.0/16 can still return a 500, the wrong tenant, or yesterday's deployment.
Plan for IPv6 before enabling a required gate. Decide whether IPv6 is prohibited, accepted in named ranges, or expected to alternate with IPv4. A failure message should print the address family and selected rule. This turns "received strange address" into an actionable configuration issue.
The costs are ongoing. Network ranges change during migrations. Region expansion adds addresses. Proxies may be introduced for security. Someone must review and update the test at the same time as infrastructure. The benefit is valuable only for boundaries where an accidental route would otherwise produce a convincing pass.
Do not use this check to map third-party infrastructure, pin CDN edges, or enforce a vendor's undocumented routing. Do not assert one IP in a mobile business flow simply because it appeared in a trace. Do not publish internal addresses in unrestricted HTML reports.
Skip it for a test served entirely from mocks or HAR when no real socket identity is part of the claim. Skip it when the only question is HTTP behavior and the environment already has stronger service identity through mutual TLS or signed responses. Skip it for backend-pod selection behind an ingress, because the reported address stops at the ingress.
The method earns its place when wrong network placement can still return a plausible response: localhost shadow services, stale sidecars, split DNS, unapproved proxies, and redirects into the wrong environment. In those cases, assert the narrowest stable network fact and leave volatile infrastructure details out of the contract.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
What does APIResponse.serverAddr return in Playwright?
It resolves to an object containing ipAddress and port for the server that answered the API request, or null when that information is unavailable. After redirects, the value describes the last request in the redirect chain.
Which Playwright version added APIResponse.serverAddr?
Version 1.61 added serverAddr() to APIResponse. Keep the test runner and its installed types on 1.61 or newer before using it, and check the runtime version if CI reports that the method is missing.
Should a test assert an exact API server IP address?
Exact IP assertions fit a process-local stub or another endpoint with a deliberately fixed address. For clusters, CDNs, and load balancers, validate an owned CIDR or environment identity instead because healthy routing can select different addresses.
Why does serverAddr show a different host after a redirect?
Redirect handling reports the address for the final request, not the first redirecting server. Set maxRedirects to 0 when the test needs to inspect the initial 3xx response and its network peer.
Is the server IP enough to prove I reached the right application?
No single address proves application identity because virtual hosts, reverse proxies, and load balancers can share an IP. Combine the address with the final URL, response contract, expected environment marker, and TLS details where HTTPS identity matters.
RELATED GUIDES
Continue the learning route
GUIDE 01
Test localStorage Directly with the Playwright API
Learn Playwright localStorage API testing through practical setup, failure analysis, CI evidence, security boundaries, and measurable release gates for QA and SDET teams.
GUIDE 02
Test sessionStorage Directly with the Playwright API
A practical guide to Playwright sessionStorage API testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 03
Assert API TLS Security Details with Playwright
Learn Playwright API response TLS security details with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.
GUIDE 04
Combine API and UI Scenarios in Playwright Agent Plans
Master Playwright agent API UI testing with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 05
18 Playwright API and UI Hybrid Testing Interview Scenarios
Master 18 senior Playwright API and UI scenarios covering request contexts, cookie sharing, setup, browser verification, cleanup, and boundary tradeoffs.