Playwright: API testing nativo — request sin browser, mismo framework

Sin Postman ni RestAssured. Playwright trae request integrado para APIs. storageState + CSRF token + 4 tests contra Serenity. Proceso real.

Terminal mostrando CSRF token encontrado sí, Status 200, Body con JSON Entities CustomerID ALFKI CompanyName Alfreds Futterkiste, 2 passed 10.5s
Status 200. Los clientes en JSON. Sin browser, sin UI. Cookies de sesión + CSRF token + Playwright request = API testing nativo funcionando.

Contexto:

En el post anterior Playwright + TypeScript: ejecución paralela experimenté con ejecución paralela. Los tests que tengo son todos de UI — abren browser, renderizan páginas, interactúan con la grilla. El más pesado tarda 11-14 segundos porque lee 91 clientes del DOM.

Esos datos vienen de una API. La grilla de Serenity llama a un endpoint REST y recibe JSON. ¿Por qué no testear ese endpoint directamente?

Playwright tiene request integrado — un APIRequestContext que hace GET, POST, PUT, DELETE sin levantar browser. Mismo expect(), mismo framework, mismo config. No es un plugin, no es una librería aparte. Viene de fábrica.

En Selenium esto no existe. Si querés testear APIs, necesitás RestAssured, HttpClient, o alguna librería separada con otra sintaxis y otro setup. En mi serie de Postman, usé Postman + Newman como herramienta dedicada. Acá la pregunta es: ¿puedo hacer lo mismo desde Playwright, sin salir del framework?


DevTools: descubriendo la API

Antes de escribir código, necesitaba saber contra qué endpoints testear. En vez de buscar documentación, hice lo que haría en un proyecto real: observar el tráfico.

Abrí Chrome, navegué a demo.serenity.is, abrí DevTools → Network → Fetch/XHR, y navegué a la grilla de Clientes.

Aparecieron 2 requests:

DevTools Network tab con request List seleccionada, Headers mostrando POST /Services/Northwind/Customer/List, Status 200 OK, Content-Type application/json
La request que trae los datos: POST a /Services/Northwind/Customer/List. Status 200, respuesta en JSON. Este es el endpoint que vamos a testear.

El GET a /Northwind/Customer carga la página. El POST a /Services/Northwind/Customer/List es la API que trae los datos. Serenity sigue el patrón /Services/Módulo/Entidad/Acción para todos sus endpoints.

Miré el payload:

DevTools Payload mostrando Request Payload con Take 100, Sort CustomerID, EqualityFilter con Country City y Representatives, IncludeColumns
El body que manda la grilla: Take 100, Sort por CustomerID, filtros vacíos. Eso es lo que vamos a replicar desde Playwright.

Y la respuesta:

DevTools Response mostrando JSON con Entities array, clientes ALFKI Alfreds Futterkiste, ANATR Ana Trujillo Emparedados, ANTON Antonio Moreno
La respuesta: un JSON con array Entities. Cada cliente con CustomerID, CompanyName, ContactName, City, Country. Los mismos datos que la grilla renderiza en UI.

JSON limpio. Un array Entities con objetos de clientes. Los mismos datos que la grilla muestra en pantalla, pero sin pasar por el DOM.


Primer intento: request desnudo

Creé tests/learning/serenity-api.spec.ts con lo mínimo:

import { test, expect } from '@playwright/test';

test.describe('API testing nativo de Playwright', () => {

    test('listar clientes via API', async ({ request }) => {
        const response = await request.post('https://demo.serenity.is/Services/Northwind/Customer/List', {
            data: {
                Take: 100,
                Sort: ['CustomerID']
            }
        });

        console.log('Status:', response.status());
        console.log('Body:', await response.text());

        expect(response.ok()).toBeTruthy();
    });

});

El fixture { request } es el APIRequestContext de Playwright. No necesita browser, no necesita page. Solo HTTP.

Resultado:

Status: 400
Body:

400 Bad Request. Body vacío. La API me rechazó sin explicación.

Era esperable: la API necesita autenticación. Sin cookies de sesión, Serenity no acepta el request.


Segundo intento: storageState sin CSRF

Ya tenía storageState configurado — el archivo .auth/user.json que guarda las cookies del login. Lo usé para crear un contexto de API autenticado:

test('listar clientes via API', async ({ playwright }) => {
    const apiContext = await playwright.request.newContext({
        baseURL: 'https://demo.serenity.is',
        storageState: '.auth/user.json'
    });

    const response = await apiContext.post('/Services/Northwind/Customer/List', {
        data: {
            Take: 100,
            Sort: ['CustomerID']
        }
    });

    console.log('Status:', response.status());
    console.log('Body:', (await response.text()).substring(0, 500));

    expect(response.ok()).toBeTruthy();

    await apiContext.dispose();
});

La idea: storageState le pasa las cookies de sesión al contexto de API. Playwright las manda automáticamente con cada request.

Resultado:

Status: 400
Body:

Mismo 400. Mismo body vacío. Las cookies no alcanzaron.


La conexión con Postman: el CSRF token

Este error me resultó familiar. En mi serie de Postman, tuve exactamente el mismo problema: requests que fallaban con 400 a pesar de tener cookies de sesión válidas.

La causa: ASP.NET Core Antiforgery. Serenity no solo verifica que estés autenticado — también verifica que cada POST incluya un token CSRF en el header x-csrf-token. Es una protección contra Cross-Site Request Forgery.

Miré .auth/user.json. Ahí estaban las 3 cookies:

{
    "cookies": [
        {
            "name": ".AspNetCore.Antiforgery_...",
            "value": "CfDJ8CI7vciz...",
            "httpOnly": true
        },
        {
            "name": ".AspNetAuth",
            "value": "CfDJ8CI7vciz...",
            "httpOnly": true,
            "secure": true
        },
        {
            "name": "CSRF-TOKEN",
            "value": "CfDJ8CI7vciz...",
            "httpOnly": false
        }
    ]
}

La cookie CSRF-TOKEN tiene httpOnly: false. Está diseñada para que JavaScript la lea y la mande como header. Es lo que el frontend de Serenity hace automáticamente en cada request. Pero Playwright no lo hace solo — hay que hacerlo explícito.


La solución: leer el CSRF del storageState

import { test, expect } from '@playwright/test';
import fs from 'fs';

const storageState = JSON.parse(fs.readFileSync('.auth/user.json', 'utf-8'));
const csrfToken = storageState.cookies.find(c => c.name === 'CSRF-TOKEN')?.value || '';

test.describe('API testing nativo de Playwright', () => {

    let apiContext;

    test.beforeAll(async ({ playwright }) => {
        apiContext = await playwright.request.newContext({
            baseURL: 'https://demo.serenity.is',
            storageState: '.auth/user.json',
            extraHTTPHeaders: {
                'x-csrf-token': csrfToken
            }
        });
    });

    test.afterAll(async () => {
        await apiContext.dispose();
    });

    // tests...
});

Leo .auth/user.json, busco la cookie CSRF-TOKEN, y la paso como extraHTTPHeaders al crear el contexto. Cada request que salga de apiContext va a incluir las cookies de sesión + el header x-csrf-token.

Resultado:

Terminal mostrando CSRF token encontrado sí, Status 200, Body con JSON Entities CustomerID ALFKI CompanyName Alfreds Futterkiste, 2 passed 10.5s
Status 200. Los clientes en JSON. Sin browser, sin UI. Cookies de sesión + CSRF token + Playwright request = API testing nativo funcionando.

Status 200. Los clientes llegaron en JSON. Sin browser, sin UI.


Los 4 tests

Con la autenticación resuelta, armé 4 tests que cubren el rango básico:

VS Code mostrando api-clientes.spec.ts líneas 1-51: imports, storageState, beforeAll con apiContext, tests listar clientes y primer cliente ALFKI
La primera mitad del spec: lectura del CSRF token, creación del contexto de API en beforeAll, tests de listar 91 clientes y validar ALFKI.
VS Code mostrando api-clientes.spec.ts líneas 53-86: test filtrar Argentina con EqualityFilter y test sin autenticación verificando status 400
La segunda mitad: filtro por Argentina (3 clientes, todos con Country Argentina) y test negativo (contexto sin cookies, 400 esperado).

Qué cubre cada uno

Listar clientes — status 200, estructura del response (Entities existe), cantidad exacta (91 clientes).

Primer cliente — validación de datos específicos. Take: 1 + Sort: ['CustomerID'] devuelve ALFKI. Verifico 5 campos: ID, empresa, contacto, ciudad, país.

Filtrar por ArgentinaEqualityFilter: { Country: 'Argentina' } devuelve 3 clientes. Itero todos y verifico que cada uno tenga Country: 'Argentina'. Es el mismo filtro que la grilla usa cuando seleccionás un país en el dropdown.

Sin autenticación — contexto limpio, sin cookies, sin CSRF. La API devuelve 400. Este es un test negativo: verifico que la API rechaza requests no autenticados.

Resultado

Reporte HTML Playwright 5 passed 0 failed, listar clientes 984ms, primer cliente ALFKI 301ms, filtrar Argentina 912ms, sin auth 408ms
5 passed (4 tests + setup de auth). El test más rápido: 301ms. Sin browser, sin renders, sin esperas de UI.

5 passed. El test de ALFKI tardó 301ms. Via UI, ese mismo tipo de validación tarda 6-8 segundos porque hay que esperar render de grilla, login visual, navegación. Acá no hay DOM, no hay CSS, no hay esperas. Solo HTTP y JSON.

Los detalles en el reporte

Playwright loguea cada paso de los tests de API igual que con UI:

Detalle test listar clientes mostrando POST Services Northwind Customer List 966ms, Expect toBeTruthy 1ms, toBeDefined 1ms, toBe 91 0ms
El reporte muestra el POST, cada assertion individual, tiempos. Mismo nivel de detalle que los tests de UI.
Detalle test primer cliente ALFKI mostrando POST 294ms y cinco Expect toBe para CustomerID, CompanyName, ContactName, City y Country
Validación de ALFKI: POST con Take 1, cinco assertions campo a campo. 301ms total.
Detalle test filtrar Argentina mostrando POST 899ms, Expect toBe para length 3, Expect toBe x3 verificando Country Argentina en cada cliente
Filtro por Argentina: 3 clientes, Playwright agrupa las assertions del loop como "Expect toBe × 3".
Detalle test sin autenticación mostrando Create request context 1ms, POST 389ms, Expect toBeFalsy 0ms y Expect toBe 400 0ms
Test negativo: contexto limpio sin cookies, POST rechazado con 400, dos assertions verificando el rechazo.

Lo que no hice (y por qué)

No hice CRUD completo (Create, Update, Delete, Retrieve). Ya lo hice en mi serie de Postman con 9 posts dedicados, incluyendo testing negativo, schema validation, data-driven con CSV, y CI/CD con Newman.

El objetivo acá no es replicar eso. Es mostrar que Playwright puede hacerlo desde el mismo framework que uso para tests de UI. Si necesitara un CRUD completo via API en este proyecto, usaría exactamente el mismo patrón: apiContext.post(), apiContext.put(), apiContext.delete(), con las mismas assertions.


Contraste con Selenium y con Postman

Con Selenium + Java

En Selenium no existe API testing nativo. Si quiero testear una API desde mi framework de Selenium, tengo que:

  1. Agregar RestAssured o HttpClient como dependencia en el pom.xml
  2. Aprender otra sintaxis de requests y assertions
  3. Manejar autenticación por separado
  4. Mantener dos sistemas distintos de reporting

Son dos mundos separados que conviven en el mismo proyecto pero no comparten nada.

Con Postman + Newman

Postman es una herramienta dedicada a API testing. Es excelente para explorar, debuggear y construir colecciones. Pero es un sistema separado: archivos JSON, variables de colección, pre-request scripts en JavaScript, Runner propio, Newman para CI/CD.

Con Playwright

request es parte del framework. Mismas assertions (expect), mismo sistema de reportes, mismo config, misma autenticación (storageState). No hay dos mundos — es un solo framework que testea UI y API.

No digo que Playwright reemplaza a Postman. Postman sigue siendo mejor para explorar y debuggear APIs interactivamente. Pero para tests automatizados que conviven con tests de UI, Playwright lo integra sin costuras.


Estado actual

4 tests de API pasando en tests/e2e/api-clientes.spec.ts. Sin browser, sin UI. El más rápido en 301ms. Autenticación resuelta reutilizando storageState + CSRF token extraído de las cookies.

El spec convive con login.spec.ts y clientes.spec.ts en e2e/. Mismo dominio (clientes), distinta vía (API vs UI). Corre junto con el resto de la suite.

Próximo post: network interception — cómo interceptar y modificar requests HTTP durante los tests de UI.

🔗 Todo el código de esta serie está en: github.com/cesarbeassuarez/playwright-typescript-framework