Mockear una petición de red en Playwright lleva tres líneas, pero la pregunta difícil es cuáles mockear: un endpoint de pago mockeado hace que el test del flujo de pago pase mientras oculta el modo de fallo exacto que los usuarios ven en producción. page.route() con route.fulfill() controla la respuesta completa; route.fallback() te permite mockear un endpoint de forma quirúrgica mientras el resto de peticiones llegan al servidor real. Este artículo cubre la API completa, los patrones de estados de error que solo son testeables con mocking, y las categorías específicas de tests donde mockear produce falsa confianza en lugar de cobertura real.

Por qué querés mockear peticiones de red

El argumento para mockear no es evitar el testing real. Es testear lo correcto en el nivel correcto.

Cuando un test de UI llega a una API real, dependes de la latencia de red, el estado del servidor y la disponibilidad de terceros. Un test que verifica cómo se renderiza una tabla cuando el endpoint /api/items devuelve un array vacío es un test de frontend. No debería requerir un estado de base de datos específico para pasar. El mocking desacopla esa preocupación por completo.

Tres razones principales para mockear:

La velocidad es la obvia. Un test que intercepta llamadas de red y devuelve una respuesta preconstruida corre en milisegundos en lugar de esperar un round-trip real.

La confiabilidad es la más importante. Los tests que llegan a backends reales fallan por razones que no tienen que ver con lo que estás testeando: el entorno de staging está caído, corrió una migración, alguien borró los datos de prueba. Las respuestas mockeadas son deterministas por definición.

Los estados de error son la razón más subestimada. No podés disparar un 503 ni un timeout de red contra un servidor real de forma confiable dentro de una suite de tests. Con page.route(), producís esas condiciones a demanda.

page.route(): el patrón de intercepción

page.route() recibe un patrón de URL (string, glob o regex) y una función handler. Cada petición que coincide pasa por el handler antes de llegar a la red.

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

test('intercepta una petición de red', async ({ page }) => {
  await page.route('https://lab.becomeqa.com/api/items', route => {
    // El handler recibe el route — vos decidís qué hacer
    console.log('Petición interceptada:', route.request().url());
    route.continue(); // Dejar pasar sin cambios
  });

  await page.goto('https://lab.becomeqa.com');
});

El handler recibe un objeto Route con cuatro métodos principales. fulfill() devuelve una respuesta mockeada, abort() bloquea la petición por completo, continue() la deja pasar, y fallback() la delega al siguiente handler que coincida. Los cuatro tienen su uso.

Los patrones glob funcionan como esperas:

// Coincidir con cualquier petición a la ruta /api/
await page.route('**/api/**', route => route.continue());

// Coincidir con un endpoint específico sin importar el origen
await page.route('**/api/items', route => route.continue());

page.route() solo intercepta peticiones de esa página específica. Si necesitás interceptar peticiones en múltiples páginas dentro de un contexto, usa browserContext.route() en su lugar.

Devolver respuestas JSON mockeadas con fulfill()

route.fulfill() cortocircuita la petición y devuelve la respuesta que tú especificas. Es el método principal del mocking de UI.

test('la tabla se renderiza con datos de API mockeados', async ({ page }) => {
  const mockItems = [
    { id: '1', destination: 'Tokyo', status: 'planned', notes: 'Cherry blossom season' },
    { id: '2', destination: 'Lisbon', status: 'completed', notes: '' },
  ];

  await page.route('**/api/items', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(mockItems),
    });
  });

  await page.goto('https://lab.becomeqa.com');
  // Pasos de login omitidos por brevedad
  
  await expect(page.getByText('Tokyo')).toBeVisible();
  await expect(page.getByText('Lisbon')).toBeVisible();
});

Controlas cada parte de la respuesta: código de estado, headers, content type y body. Si la aplicación verifica headers de respuesta (como Content-Type), inclúyelos explícitamente.

Para payloads mock más grandes, cárgalos desde un archivo JSON de fixtures:

import { readFileSync } from 'fs';
import path from 'path';

test('la tabla se renderiza con datos del fixture', async ({ page }) => {
  const fixtureBody = readFileSync(
    path.join(__dirname, 'fixtures/items.json'),
    'utf-8'
  );

  await page.route('**/api/items', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: fixtureBody,
    });
  });

  await page.goto('https://lab.becomeqa.com');
  await expect(page.getByRole('table')).toBeVisible();
});

Esto mantiene los archivos de test legibles cuando los datos mockeados son complejos. Un registro de pago realista o un perfil de usuario profundamente anidado no pertenece inline.

Bloquear peticiones con abort()

A veces quieres verificar qué pasa cuando una petición no puede completarse: una imagen que no carga, un script de analytics de terceros que se queda sin respuesta, o una llamada a API no crítica que la aplicación debería manejar bien.

test('la app muestra estado de error cuando la API no está disponible', async ({ page }) => {
  await page.route('**/api/items', route => {
    route.abort('failed'); // Simula un fallo de conexión
  });

  await page.goto('https://lab.becomeqa.com');
  // Login, navegar a la vista de ítems...
  
  // La app debería mostrar un mensaje de error, no crashear
  await expect(page.getByText('Unable to load items')).toBeVisible();
  await expect(page.getByRole('table')).not.toBeVisible();
});

El método abort() acepta un código de error: 'failed' para un error de conexión genérico, 'timedout' para comportamiento de timeout, 'blockedbyclient' para simular un bloqueo estilo ad-blocker. Usa 'timedout' para testear el manejo de timeouts específicamente:

test('muestra mensaje de timeout después de respuesta lenta', async ({ page }) => {
  await page.route('**/api/items', route => {
    route.abort('timedout');
  });

  await page.goto('https://lab.becomeqa.com');
  await expect(page.getByText('Request timed out')).toBeVisible();
});

Bloquear también sirve para acelerar tests descartando peticiones que sabés que son irrelevantes. Bloquear analytics de terceros, CDNs de fuentes o scripts de tracking puede reducir segundos de una suite de tests:

test.beforeEach(async ({ page }) => {
  // Bloquear peticiones de analytics — son irrelevantes y lentas
  await page.route(/google-analytics\.com|segment\.io/, route => route.abort());
});

Modificar peticiones en vuelo con continue()

route.continue() deja pasar la petición al servidor pero te permite sobreescribir cualquier parte de ella primero: URL, método, headers o body. Es útil para inyectar headers de auth sin modificar el código de la app, o para testear cómo el backend maneja combinaciones específicas de headers.

test('inyecta header de auth en cada petición a la API', async ({ page }) => {
  await page.route('**/api/**', route => {
    route.continue({
      headers: {
        ...route.request().headers(),
        'Authorization': 'Bearer test-token-for-e2e',
      },
    });
  });

  await page.goto('https://lab.becomeqa.com/dashboard');
  await expect(page.getByRole('table')).toBeVisible();
});

También podés reescribir la URL de la petición, lo que es útil para redirigir llamadas a la API de producción a un entorno de staging sin cambiar ninguna configuración:

test('redirige llamadas a la API al entorno de staging', async ({ page }) => {
  await page.route('https://api.production.com/**', route => {
    const newUrl = route.request().url().replace(
      'api.production.com',
      'api.staging.becomeqa.com'
    );
    route.continue({ url: newUrl });
  });

  await page.goto('https://lab.becomeqa.com');
});

Cuando uses continue() con headers personalizados, siempre haz spread de route.request().headers() primero. Reemplazar los headers por completo va a eliminar cosas como Content-Type y Accept que el servidor puede necesitar.

Interceptar e inspeccionar con waitForRequest y waitForResponse

A veces el objetivo no es mockear nada. Es verificar que una petición específica realmente se realizó, o capturar los datos de respuesta para hacer aserciones. page.waitForRequest() y page.waitForResponse() devuelven promesas que se resuelven cuando se detecta una petición o respuesta que coincide.

test('hacer clic en Guardar envía el payload correcto', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  // Pasos de login...
  
  // Configurar el listener ANTES de disparar la acción
  const requestPromise = page.waitForRequest(request =>
    request.url().includes('/api/items') && request.method() === 'POST'
  );

  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Destination').fill('Berlin');
  await page.getByRole('button', { name: 'Save' }).click();

  const request = await requestPromise;
  const payload = request.postDataJSON();

  expect(payload.destination).toBe('Berlin');
  expect(payload.status).toBeDefined();
});

El detalle crítico: configurá el listener antes de disparar la acción. Si primero esperás el click del botón y después esperás waitForRequest, la petición puede haberse disparado ya y vas a estar esperando una petición que nunca va a llegar.

waitForResponse funciona igual, pero se resuelve con la respuesta:

test('el formulario de pago muestra éxito después de que la API confirma', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/payment');
  
  const responsePromise = page.waitForResponse(response =>
    response.url().includes('/api/payments') && response.status() === 200
  );

  await page.getByLabel('Card Number').fill('4111111111111111');
  await page.getByRole('button', { name: 'Pay' }).click();

  const response = await responsePromise;
  const body = await response.json();

  expect(body.status).toBe('success');
  await expect(page.getByText('Payment confirmed')).toBeVisible();
});

Testear estados de error: 500s, 401s y timeouts

El testing de estados de error es donde page.route() demuestra su valor. Son escenarios casi imposibles de disparar de forma confiable contra un backend real, pero simples de mockear.

500 Internal Server Error

test('muestra banner de error en fallo del servidor', async ({ page }) => {
  await page.route('**/api/items', route => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    });
  });

  await page.goto('https://lab.becomeqa.com');
  // Login...
  await expect(page.getByRole('alert')).toContainText('Something went wrong');
});

401 Unauthorized (sesión expirada)

test('redirige al login cuando la sesión expira', async ({ page }) => {
  let requestCount = 0;

  await page.route('**/api/items', route => {
    requestCount++;
    if (requestCount === 1) {
      // La primera petición tiene éxito — el usuario está "logueado"
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([{ id: '1', destination: 'Madrid', status: 'planned' }]),
      });
    } else {
      // Las peticiones siguientes devuelven 401 — sesión expirada
      route.fulfill({
        status: 401,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Session expired' }),
      });
    }
  });

  await page.goto('https://lab.becomeqa.com');
  // Verificar que la carga inicial funciona, luego disparar un reload...
  await page.reload();
  await expect(page).toHaveURL(/\/login/);
});

Timeout de red

test('muestra botón de reintentar después del timeout de red', async ({ page }) => {
  await page.route('**/api/items', async route => {
    // Delay y luego abort — simula una red lenta que hace timeout
    await new Promise(resolve => setTimeout(resolve, 8000));
    route.abort('timedout');
  });

  await page.goto('https://lab.becomeqa.com');
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible({
    timeout: 15000,
  });
});

route.fallback() para mocking quirúrgico

route.fallback() permite que un handler se haga a un lado y deje que el siguiente handler que coincida (o la red real) tome el control. Es la herramienta correcta cuando querés mockear endpoints específicos mientras el resto llega al servidor real.

test('mockea solo el endpoint de pagos', async ({ page }) => {
  // Primer handler: mockear el endpoint de pagos
  await page.route('**/api/payments', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ status: 'success', transactionId: 'mock-txn-001' }),
    });
  });

  // Segundo handler: dejar pasar el resto
  await page.route('**', route => route.fallback());

  await page.goto('https://lab.becomeqa.com');
  // Login real, carga de datos real — solo pagos está mockeado
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();

  await page.goto('https://lab.becomeqa.com/payment');
  await page.getByRole('button', { name: 'Pay' }).click();
  await expect(page.getByText('Payment confirmed')).toBeVisible();
});

route.fallback() es lo que separa el mocking quirúrgico del mocking todo-o-nada. Podés reemplazar una dependencia externa frágil mientras mantenés el resto del test anclado en comportamiento real.

Múltiples handlers para la misma ruta se evalúan en orden inverso al de registro. El último en registrarse es el primero en evaluarse. Cuando un handler llama a fallback(), Playwright pasa al handler registrado anteriormente.

// Registrado primero — actúa como default
await page.route('**/api/**', route => route.continue());

// Registrado segundo — evaluado primero
await page.route('**/api/payments', route => {
  route.fulfill({ status: 200, body: JSON.stringify({ status: 'success' }) });
});

Cuándo NO mockear

Mockear tiene un costo: tus tests son tan buenos como tus datos mockeados. Si la API real devuelve una estructura que no anticipaste en tu fixture, los tests mockeados van a pasar mientras producción falla.

Hay categorías de tests donde mockear te perjudica activamente.

Los tests de contrato necesitan llegar a la API real. Si estás verificando que una petición del frontend coincide con el contrato esperado del backend (nombres de campos, headers requeridos, estructura de respuesta), un mock no puede detectar el desajuste. Para eso existe la API real.

Los tests de integración para flujos críticos deberían usar el backend real. El flujo de login, el flujo de pago, el envío de datos que impulsa el negocio necesitan integración real para capturar los modos de fallo reales. Mockearlos y estás testeando confianza, no comportamiento.

Cuando estés depurando un bug real, el mocking te impide ver el problema actual. Si los usuarios reportan un problema en la página de confirmación de pago, lo último que querés es un endpoint de pago mockeado que oculta la respuesta real.

La regla práctica: mockea para hacer un test determinista y rápido cuando la petición de red no es lo que estás testeando. No mockees cuando la petición en sí, o el servidor que la maneja, está bajo test. Una suite completa usa ambos enfoques: llamadas reales a la API para tests de integración y contratos, respuestas mockeadas para renderizado de UI y tests de estados de error.

FAQ

¿page.route() afecta peticiones que comienzan antes de registrar el handler?

No. Los handlers solo interceptan peticiones que se inician después del registro. Siempre llamá page.route() antes de page.goto() o antes de la acción que dispara la petición.

¿Puedo enrutar la misma URL con múltiples handlers?

Sí. Playwright evalúa los handlers en orden inverso al de registro. Usa route.fallback() para pasar el control al siguiente handler. Usa page.unroute() para remover un handler cuando ya no lo necesitas.

¿Cómo mockeo una respuesta de streaming? route.fulfill() no soporta streaming de forma nativa. Envía el body completo de una vez. Para escenarios de streaming, necesitás un servidor de test local o una herramienta como msw (Mock Service Worker) integrada junto a Playwright. ¿Los datos mockeados deberían estar en los archivos de test o en archivos de fixtures?

Los objetos mock cortos (2 a 3 campos) están bien inline. Todo lo que sea más grande o se reutilice en varios tests pertenece a un directorio fixtures/. Esto mantiene los archivos de test enfocados en el comportamiento, no en la configuración de datos.

¿Cuál es la diferencia entre page.route() y Service Workers para mocking? page.route() intercepta a nivel de Playwright, antes del stack de red del navegador. Los Service Workers interceptan dentro del navegador. Para tests de Playwright, page.route() es más simple, más confiable, y no requiere configuración en el código de la aplicación. Los Service Workers son útiles cuando necesitas que el mock persista entre navegaciones completas de página o afecte la caché del service worker. → See also: API Testing con Playwright: Más Allá de la UI | Construyendo un Framework de Tests de Playwright Escalable desde Cero | Pruebas de API con el APIRequestContext de Playwright (Sin Postman)