Intercept Everything, API Logging Middleware for Playwright

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:

  • Parallel CI runs
  • Dynamic test data
  • Multiple services
  • Generated API clients

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.

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:

  • test.info().attach() puts structured data directly into the HTML report
  • response.clone() prevents breaking your test code
  • JSON formatting keeps everything readable and consistent

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:

  • Performance monitoring — track latency per request and surface slow endpoints
  • Retries — automatically handle 429 / 503 responses without polluting test logic
  • Security — redact sensitive headers before attaching data
  • Auth handling — refresh tokens transparently

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.

To view or add a comment, sign in

More articles by Viatsheslav (Slavik) Pashanin

Others also viewed

Explore content categories