Intercept Everything, API Logging Middleware for Playwright
Playwright is a phenomenal tool—right up until your CI fails and you have no idea why.
You get a single line:
expected 200, received 400
No request. No response. No context.
Welcome to what I call CI Archaeology.
You dig through logs, rerun the test locally, add temporary console.log statements, and hope to reproduce the failure before the environment changes again. Sometimes you get lucky. Most of the time, you don’t.
The core issue is simple:
Playwright does not provide a native way to persist full API request and response data across your test suite.
And in modern systems, that’s not optional it’s critical.
The Invisible Request
By default, every API call in your tests is an Invisible Request.
It executes. It affects the test outcome. And then it disappears.
This becomes a real problem when you’re working with:
When a test fails, the only thing you get is the assertion result. Everything else, the actual evidence is gone, and without that evidence, debugging turns into guesswork.
Stop Logging in Your Tests
Most teams react to this problem the same way: they start adding console.log statements inside tests.
It works, well at least temporarily, but in parallel CI runs, logs become interleaved noise. The signal disappears, and you’re back where you started.
The problem isn’t missing logs, The problem is where you’re logging.
Instead of instrumenting individual tests, the right move is to instrument the network boundary.
The Interception Point: Middleware
If you’re using an OpenAPI generator like typescript-fetch, you already have exactly what you need: a middleware layer, every API call flows through it, that gives you a single, reliable interception point, before the request is sent and after the response is received.
This is the critical shift:
You don’t instrument tests — you instrument the client.
Once you do that, logging becomes automatic, consistent, and impossible to forget.
Building the Evidence Engine
We don’t want noisy console logs! We want structured, reliable data, attached directly to the Playwright HTML report, the place engineers already go when something fails.
Recommended by LinkedIn
Here’s the middleware that captures the full request/response cycle and injects it into the report.
import test from '@playwright/test';
import { Middleware, RequestContext, ResponseContext } from './api-client';
export const playwrightLoggingMiddleware: Middleware = {
pre: async (context: RequestContext) => {
const method = context.init.method || 'GET';
const url = context.url;
const path = new URL(url).pathname;
const headers = context.init.headers as Record<string, string> | undefined;
let body;
if (context.init.body) {
try {
body = typeof context.init.body === 'string'
? JSON.parse(context.init.body)
: context.init.body;
} catch {
body = '[non-JSON body omitted]';
}
}
await test.info().attach(`REQ: ${method} ${path}`, {
body: JSON.stringify({ url, method, headers, body }, null, 2),
contentType: 'application/json',
});
},
post: async (context: ResponseContext) => {
const method = context.init.method || 'GET';
const path = new URL(context.url).pathname;
const headers = context.response.headers;
let body;
if (headers.get('content-type')?.includes('application/json')) {
/**
* CRITICAL: Use .clone().
* Fetch streams can only be read once. Without cloning,
* your test code will receive an empty body.
*/
body = await context.response.clone().json();
}
await test.info().attach(`RES: ${context.response.status} ${path}`, {
body: JSON.stringify({
status: context.response.status,
headers: Object.fromEntries(headers.entries()),
body,
}, null, 2),
contentType: 'application/json',
});
},
};
A few important details:
This isn’t debugging anymore, it’s evidence collection.
Wire Once, Use Everywhere
This is where the approach becomes powerful.
You don’t touch your tests, you wire the middleware once, at the client level:
// api-factory.ts
import { Configuration, UserApi, OrderApi } from './generated-api';
const config = new Configuration({
basePath: process.env.API_BASE_URL,
middleware: [playwrightLoggingMiddleware],
});
export const userApi = new UserApi(config);
export const orderApi = new OrderApi(config);
From that moment on:
Every API call records itself, no decorators. No repeated setup. No developer discipline required.
Once again:
You don’t instrument tests ,you instrument the client.
The “After” Experience
The next time a test fails with a 400 Bad Request, you don’t guess.
You open the Playwright HTML report and check the attachments.
Request
{
"items": [
{ "id": "uuid-123", "qty": 0 }
]
}
Response
{
"code": "VALIDATION_ERROR",
"message": "Quantity must be greater than zero."
}
Beyond Debugging: Composable Middleware
Middleware is composable. Logging is just the start.
Once this layer exists, you can extend it naturally:
All of this happens in one place, without touching test code.
When you capture what actually happens on the wire, your test suite stops being a simple signal and becomes a source of truth.