Testing de API con Playwright: una suite que funciona, y lo que añadieron 1.62 a 1.64

Compartir

Casi todo el mundo conoce Playwright como herramienta de testing de navegador. Pero también trae un cliente HTTP completo, el fixture request, y con él puedes escribir tests de API sin abrir ningún navegador. Los tests corren en el mismo runner, con los mismos informes, reintentos y workers en paralelo que tus tests de interfaz.

En esta entrada montamos con él una pequeña suite de tests de API contra nuestra API de pruebas gratuita. Cada ejemplo se ejecutó con Playwright 1.64.0, publicado el 7 de octubre de 2026, y los 12 tests pasan. La última parte repasa lo que han añadido las tres últimas versiones para el testing de API: respuestas tipadas, bloqueos para tests que comparten datos, cookies en el contexto de peticiones, tiempos de respuesta y cancelar una petición.

Preparación

Necesitas Node.js 20 o posterior. Los tests de API no necesitan navegadores, así que no hay paso de npx playwright install:

Terminal
$ mkdir playwright-api && cd playwright-api
$ npm init -y
$ npm install --save-dev @playwright/test@1.64.0

La configuración define una URL base, para que los tests usen rutas como /api/Products, y una cabecera Accept por defecto para cada petición:

playwright.config.ts
import { defineConfig } from "@playwright/test";

// API tests only: no browsers are launched, so `npx playwright install` isn't needed.
export default defineConfig({
  testDir: "./tests",
  reporter: "list",
  retries: 1,
  // Playwright 1.62: run retries at the end, one at a time, instead of straight away in parallel.
  retryStrategy: "isolated",
  use: {
    // Point the tests at another server with BASE_URL, e.g. a copy of the API on your machine.
    baseURL: process.env.BASE_URL ?? "https://api.testingapis.com",
    extraHTTPHeaders: { Accept: "application/json" },
  },
});

BASE_URL te permite apuntar los mismos tests a otro servidor, como una copia local de la API o un entorno de staging, sin tocarlos.

Tu primer test de API

Pide el fixture request en un test y recibes un APIRequestContext con get, post, put, patch, delete y fetch. Cada uno devuelve un APIResponse con el estado, las cabeceras y el cuerpo. Este archivo comprueba un endpoint de lista, un cuerpo de error, un error de validación y unos cuantos códigos de estado:

tests/read.spec.ts
import { expect, test } from "@playwright/test";
import type { ApiError, Product } from "./types";

test("lists products filtered by category, with paging headers", async ({ request }) => {
  const response = await request.get<Product[]>("/api/Products", {
    params: { category: "electronics", limit: 2 },
  });

  expect(response.status()).toBe(200);
  expect(response.headers()["content-type"]).toContain("application/json");
  expect(Number(response.headers()["x-total-count"])).toBeGreaterThan(2);

  const products = await response.json();
  expect(products).toHaveLength(2);
  for (const product of products) {
    expect(product.category).toBe("electronics");
    expect(typeof product.price).toBe("number");
  }
});

test("returns 404 with an error body for a product that doesn't exist", async ({ request }) => {
  const response = await request.get<ApiError>("/api/Products/99999");

  expect(response.status()).toBe(404);
  expect(await response.json()).toEqual({
    status: 404,
    error: "Not Found",
    message: "Product with ID 99999 does not exist.",
  });
});

test("rejects a product without a title", async ({ request }) => {
  const response = await request.post<ApiError>("/api/Products", { data: { price: 5 } });

  expect(response.status()).toBe(400);
  expect((await response.json()).message).toBe("Missing required field: title");
});

for (const code of [400, 401, 429, 500, 503]) {
  test(`handles a ${code} response`, async ({ request }) => {
    const response = await request.get(`/api/status/${code}`);

    expect(response.status()).toBe(code);
    expect(response.ok()).toBe(false);
  });
}
  • params construye la query string, así que no tienes que codificar ?category=electronics&limit=2 a mano.
  • response.headers() devuelve los nombres de cabecera en minúsculas. Nuestra API devuelve el total de productos que coinciden en X-Total-Count, así que el test comprueba la paginación además del cuerpo.
  • data en un post se envía como JSON, con Content-Type: application/json ya puesto.
  • El bucle for crea un test por código de estado. /api/status/{code} devuelve el estado que pidas, lo que viene muy bien para ver cómo reacciona tu propio cliente ante un 429 o un 503.

Un 404 o un 500 no hace que request.get lance una excepción. Recibes la respuesta y haces aserciones sobre ella, que es lo que quieres cuando el error es justo lo que estás probando. Cuando un estado de error simplemente debe hacer fallar el test, pasa failOnStatusCode:

typescript
// Throw on any 4xx or 5xx instead of returning the response.
const response = await request.get("/api/Products/1", { failOnStatusCode: true });

Tipar las respuestas

Desde Playwright 1.63, los métodos de petición aceptan un argumento de tipo, y response.json() devuelve ese tipo. Es lo que hacía request.get<Product[]> arriba. Los tipos viven en un archivo:

tests/types.ts
// The shapes the tests read. Only the fields they check are listed.

export interface Product {
  id: number;
  title: string;
  price: number;
  category: string;
  inStock: boolean;
}

/** What POST and PUT on /api/Products return. */
export interface ProductEnvelope {
  message: string;
  data: Product;
}

/** The API's error body, for 4xx and 5xx responses. */
export interface ApiError {
  status: number;
  error: string;
  message: string;
}

Es una pista para TypeScript, no una comprobación. Si la API envía una cadena donde declaraste un número, el test compila y se ejecuta igual. Mantén las aserciones sobre los valores que importan, como hacen los tests de arriba con typeof product.price. Para validar cuerpos de respuesta completos contra un esquema, mira nuestra lección de validación de respuestas (en inglés).

Un test de crear, leer, actualizar y borrar que limpia después

Los productos de nuestra API son compartidos: un producto que creas lo ve todo el mundo hasta que se borra. Los tests contra un entorno compartido tienen que limpiar lo que dejan, también cuando fallan a mitad de camino:

tests/crud.spec.ts
import { expect, test } from "@playwright/test";
import type { Product, ProductEnvelope } from "./types";

// Products are shared by everyone who uses the API. Playwright 1.63's lock keeps any other test that writes
// products from running at the same time as this file, in any worker.
test.describe.configure({ lock: "products" });

test("creates, reads, updates and deletes a product", async ({ request }) => {
  const created = await request.post<ProductEnvelope>("/api/Products", {
    data: { title: "Playwright test product", price: 12.5, category: "electronics" },
  });
  expect(created.status()).toBe(201);
  const { data: product } = await created.json();

  try {
    await test.step("read it back", async () => {
      const read = await request.get<Product>(`/api/Products/${product.id}`);
      expect(read.status()).toBe(200);
      expect(await read.json()).toMatchObject({ id: product.id, title: "Playwright test product", price: 12.5 });
    });

    await test.step("change the price", async () => {
      const updated = await request.put<ProductEnvelope>(`/api/Products/${product.id}`, {
        data: { title: "Playwright test product", price: 9.99, category: "electronics" },
      });
      expect(updated.status()).toBe(200);
      expect((await updated.json()).data.price).toBe(9.99);
    });
  } finally {
    // Clean up even when a step fails, so a broken run doesn't leave products behind.
    const deleted = await request.delete(`/api/Products/${product.id}`);
    expect(deleted.status()).toBe(200);
  }

  const gone = await request.get(`/api/Products/${product.id}`);
  expect(gone.status()).toBe(404);
});
  • El bloque finally borra el producto aunque falle la lectura o la actualización, así que una ejecución rota no deja datos de prueba por ahí.
  • test.stepnombra cada fase en el informe y en el trace viewer, así que un fallo dice "change the price" en lugar de señalar la línea 23.
  • La última petición comprueba que el producto ha desaparecido de verdad. Un borrado que devuelve 200 pero deja el registro en su sitio es un bug real, y solo una lectura posterior lo detecta.
  • test.describe.configure({ lock: "products" }) es nuevo en 1.63. Los tests que comparten un nombre de bloqueo nunca se ejecutan a la vez, ni entre archivos ni entre workers. Dale el mismo bloqueo a todos los tests que escriben productos y no podrán pisarse los datos, mientras el resto de la suite sigue en paralelo.

Nuestra lección de testing CRUD explica qué comprobaciones necesita cada uno de estos pasos, y por qué borrar dos veces también es un buen test.

Lo que 1.62 a 1.64 añadieron para los tests de API

VersiónNovedadPara qué sirve
1.64request.addCookies() / cookies() / clearCookies()Poner o quitar cookies en un contexto de peticiones, igual que en un contexto de navegador
1.64--shuffle [seed]Ejecutar los tests en orden aleatorio para encontrar tests que solo pasan después de otro
1.63request.get<T>()response.json() tipado, como se ve arriba
1.63lockEvitar que los tests que cambian los mismos datos se ejecuten a la vez
1.62apiResponse.timing()Tiempos de DNS, conexión, petición y respuesta de una llamada
1.62signalCancelar una petición con un AbortSignal
1.62retryStrategy: "isolated"Hacer los reintentos al final, de uno en uno

Este archivo prueba las que afectan a las peticiones. Nuestro endpoint /api/echo devuelve la petición que recibió, así que es fácil ver exactamente qué envió Playwright:

tests/new-in-1-62-to-1-64.spec.ts
import { expect, test } from "@playwright/test";

// /api/echo returns what it received, which makes it easy to see what the request context sent.
interface Echo {
  method: string;
  cookies: Record<string, string>;
}

test("1.64: cookies added to the request context are sent with every request", async ({ request, baseURL }) => {
  await request.addCookies([{ name: "session-id", value: "42", url: baseURL! }]);

  const withCookie = await request.get<Echo>("/api/echo");
  expect((await withCookie.json()).cookies).toEqual({ "session-id": "42" });

  await request.clearCookies({ name: "session-id" });
  const withoutCookie = await request.get<Echo>("/api/echo");
  expect((await withoutCookie.json()).cookies).toEqual({});
});

test("1.62: apiResponse.timing() shows where the time went", async ({ request }) => {
  const response = await request.get("/api/health");
  const timing = response.timing();

  // Each value is milliseconds after startTime, or -1 when it doesn't apply (no DNS lookup on a reused
  // connection, for example).
  expect(timing.startTime).toBeGreaterThan(0);
  expect(timing.responseEnd).toBeGreaterThanOrEqual(timing.responseStart);
  expect(timing.responseEnd).toBeLessThan(5_000);
});

test("1.62: an AbortSignal cancels a request that takes too long", async ({ request }) => {
  // ?delay= makes the API wait before answering. The signal gives up after 500 ms.
  const pending = request.get("/api/echo", { params: { delay: 3000 }, signal: AbortSignal.timeout(500) });

  await expect(pending).rejects.toThrow();
});
  • Cookies. Un contexto de peticiones siempre ha guardado las cookies que ponen las respuestas, y podía empezar con algunas desde storageState, pero no podías añadir ni quitar una directamente. Ahora sí, en mitad de un test: por ejemplo, borra la cookie de sesión y comprueba que la siguiente petición se rechaza.
  • Tiempos. timing() devuelve los mismos campos que la Resource Timing API del navegador, en milisegundos desde startTime, con -1 para las fases que no ocurrieron. Una conexión reutilizada no hace consulta DNS, así que comprueba responseEnd en lugar de sumar todos los campos. El límite de 5 segundos del test es holgado a propósito: una aserción de tiempos ajustada contra un servidor en internet vuelve inestable el test.
  • Cancelar. Las notas de la versión 1.62 describen signal para acciones y aserciones. También funciona en los métodos de petición, cosa que comprobamos en las definiciones de tipos de 1.64 y en el test de arriba: ?delay=3000 hace que nuestra API espere tres segundos, y AbortSignal.timeout(500) se rinde a los cinco décimos. La opción timeout de la propia petición hace algo parecido; una señal es útil cuando otra cosa decide cuándo parar, como un test que termina antes de tiempo.

La configuración de arriba ya usa retryStrategy: "isolated". Por defecto, un test que falla se reintenta en cuanto queda libre un worker, mientras el resto de la suite sigue en marcha. Los reintentos aislados esperan al final y se ejecutan de uno en uno. Si un test falla en la ejecución en paralelo pero pasa en su reintento aislado, lo probable es que otro test se le cruce, como dos tests cambiando el mismo registro, y la solución es un lock. --shuffle encuentra el problema contrario: ejecuta la suite en orden aleatorio de vez en cuando y aparecerá cualquier test que solo pase después de otro.

Terminal
$ npx playwright test --shuffle

Ejecutar la suite

Terminal
$ npx playwright test
Salida (Playwright 1.64.0, Node 24, 14 de octubre de 2026)
Running 12 tests using 3 workers

  ✓   1 tests\read.spec.ts:4:5 › lists products filtered by category, with paging headers (285ms)
  ✓   2 tests\new-in-1-62-to-1-64.spec.ts:9:5 › 1.64: cookies added to the request context are sent with every request (263ms)
  ✓   3 tests\crud.spec.ts:8:5 › creates, reads, updates and deletes a product (334ms)
  ✓   4 tests\read.spec.ts:21:5 › returns 404 with an error body for a product that doesn't exist (90ms)
  ✓   5 tests\new-in-1-62-to-1-64.spec.ts:20:5 › 1.62: apiResponse.timing() shows where the time went (104ms)
  ✓   6 tests\read.spec.ts:32:5 › rejects a product without a title (118ms)
  ✓   8 tests\read.spec.ts:40:7 › handles a 400 response (109ms)
  ✓   9 tests\read.spec.ts:40:7 › handles a 401 response (107ms)
  ✓  10 tests\read.spec.ts:40:7 › handles a 429 response (120ms)
  ✓   7 tests\new-in-1-62-to-1-64.spec.ts:31:5 › 1.62: an AbortSignal cancels a request that takes too long (512ms)
  ✓  11 tests\read.spec.ts:40:7 › handles a 500 response (107ms)
  ✓  12 tests\read.spec.ts:40:7 › handles a 503 response (97ms)

  12 passed (2.1s)

La suite entera tarda unos dos segundos. Descárgala como playwright-api-examples.zip, ejecuta npm install y npx playwright test, y correrá contra la misma API. Todos los endpoints que usa son gratuitos, sin cuenta ni clave.

Llamadas a la API dentro de tests de interfaz

El mismo cliente está disponible en los tests de navegador como page.request. Comparte cookies con la página, así que puedes crear datos de prueba o iniciar sesión por la API y luego comprobar el resultado en la interfaz, mucho más rápido que rellenar formularios para preparar cada test:

typescript
test("shows a product created through the API", async ({ page }) => {
  // page.request shares cookies with the page, so a session set up here carries over to the browser.
  const created = await page.request.post("https://api.testingapis.com/api/Products", {
    data: { title: "Seeded", price: 1 },
  });
  // ...then open the page that lists products and check it appears.
});

Los endpoints que llaman estos tests están documentados en la documentación de la API de productos y la de la API de códigos de estado, con un botón Send para probar cada petición antes de escribir su test.