Abrir un buzón

Pruebas y CI

Pruebas de email con Playwright: leer un código real

Un test de Playwright puede rellenar un formulario de registro en dos segundos y después no tiene ni idea de qué pasó a continuación, porque el siguiente paso es un correo. Aquí está el fixture que lo lee — un mensaje real, de un buzón real, sin clave de API — y los tres hábitos que evitan que el test se vuelva inestable.

  • Intermedio
  • 20 min de lectura
Una ventana de navegador gris con una flecha de cursor azul, un sobre azul que entra por una ranura lateral y un cronómetro gris delante

Dónde suele detenerse un test de Playwright

La mayoría de los tests de registro terminan en la frase «revisa tu correo». El formulario se rellenó, se pulsó el botón, la página dijo lo correcto — y todo lo que pasa después de esa frase se da por hecho. Si el correo salió, si el código que contiene es el que espera el servidor, si el enlace de confirmación abre una página que funciona: todo eso se deja para producción.

Se deja ahí porque el siguiente paso es asíncrono y vive fuera del navegador, y Playwright no tiene nada en lo que hacer clic. Las tres formas habituales de sortear eso demuestran cada una algo distinto:

Simular el mailer
Demuestra que tu código llamó a send(). No demuestra nada sobre la plantilla, el enlace, ni el proveedor que rechazó el mensaje.
Un sumidero SMTP local (Mailpit, MailHog, smtp4dev)
Demuestra que un mensaje bien formado salió de la aplicación. Un servicio más en CI, y nada de lo que solo ocurre en la internet pública — una búsqueda MX real, un proveedor real, un destinatario real — ocurre aquí.
Un buzón desechable real
Demuestra que el mensaje salió de la aplicación, cruzó internet, fue aceptado por un servidor de correo real y lleva un código que funciona. El único coste es que el test tiene que esperar correctamente — que es de lo que trata toda esta guía.

La API detrás de esto son tres endpoints sin clave, documentados en la referencia. Si quieres la disciplina general antes de entrar en lo específico de Playwright, probar un flujo de verificación de principio a fin lo cubre para cualquier ejecutor; esta es la versión para Playwright, con el fixture que lo hace agradable.

Un fixture que le da a cada test su propio buzón

El test.extend de Playwright es el sitio adecuado para esto: un buzón se convierte en algo que un test pide por nombre, como page, y la dirección se inventa de cero cada vez. No hay que crear nada en el servidor — un buzón existe en cuanto le llega correo — así que el fixture es una clase con una dirección aleatoria dentro y tres métodos pequeños.

tests/fixtures.ts
import { test as base, expect } from '@playwright/test';

const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';

export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export class Inbox {
  readonly address: string;

  /** A mailbox nothing else in this run, or any previous run, is using. */
  constructor(prefix = 'e2e') {
    this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
  }

  /** Block until a matching message arrives, or the deadline passes. */
  async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
    const deadline = Date.now() + (opts.timeoutMs ?? 60_000);

    while (Date.now() < deadline) {
      const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.address)}`);

      if (res.status === 429) {                       // slow down, do not fail
        await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
        continue;
      }
      if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${this.address}`);

      const { messages } = (await res.json()) as { messages: Summary[] };
      const hit = messages.find(m =>
        (!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
        (!opts.fromContains    || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
      if (hit) return this.read(hit.id);

      await sleep(1000);                              // one read a second, never throttled
    }
    throw new Error(`no message for ${this.address} within the deadline`);
  }

  async read(id: string): Promise<Message> {
    const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
    if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
    return res.json() as Promise<Message>;
  }

  /** Optional: everything expires on its own after a few days. Idempotent. */
  async delete(id: string): Promise<void> {
    await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
  }
}

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    await use(new Inbox());
  },
});

export { expect };

En ese archivo hay dos cosas deliberadas. La dirección es aleatoria por test, no por archivo ni por ejecución, así que los workers en paralelo nunca pueden leer el correo del otro. Y waitFor devuelve el mensaje completo en lugar del resumen — en la práctica siempre quieres el cuerpo a continuación, y una llamada menos en cada test se nota.

Esperar el mensaje sin usar sleep

El correo no es síncrono. Normalmente llega en dos o tres segundos y de vez en cuando tarda veinte, y la forma en que el test espera decide si se puede confiar en la batería de pruebas. Las reglas son breves:

  • Un plazo, no un número de intentos. for (let i = 0; i < 30; i++) son treinta intentos a la velocidad que le toque correr al bucle — más corto cuanto más rápida sea la API, más largo cuanto más lento se vuelva tu remitente. Un plazo de reloj real significa lo mismo en cualquier máquina.
  • Una lectura por segundo. Ese es el ritmo documentado y nunca se limita. Ir más rápido se rechaza con 429 y una cabecera Retry-After, y consultar más rápido no haría que el correo llegara antes.
  • Nada de waitForTimeout. Una espera fija es demasiado corta en un día lento o demasiado larga cualquier otro día. El bucle se detiene en el momento en que el mensaje existe.
  • Filtra; no cojas el mensaje más reciente a ciegas. Pasa subjectContains o fromContains. Cuando un flujo envía dos mensajes — uno de bienvenida y uno con el código — el más reciente no siempre es el que quieres.

Los códigos de estado con los que se va a encontrar el bucle, y qué debe hacer con cada uno:

CódigoSignificaQué hace el bucle
200Se leyó el buzón. count puede ser 0 — un buzón vacío nunca es un 404.Busca una coincidencia; si no hay ninguna, duerme un segundo e inténtalo de nuevo.
400La dirección tiene un formato incorrecto.Lanza una excepción. Reintentar una errata no la arregla.
404El dominio no está alojado aquí.Lanza una excepción, y comprueba el registro MX si es tu propio dominio.
429Más de una lectura por segundo para esa dirección, o más de 1200 solicitudes por minuto desde este ejecutor.Duerme los segundos que indique Retry-After y continúa. Nunca hagas fallar el test por un 429.

Sacar el código, o el enlace, del mensaje

El mensaje vuelve con las dos partes, y cuál analizar depende de lo que envíe tu aplicación:

text
La parte en texto plano. Analiza esta cuando exista — sin marcado, y un código de seis dígitos es un código de seis dígitos.
html
La parte en HTML, o null cuando el remitente solo envió texto. Los enlaces de confirmación a menudo solo están aquí, dentro de un <a href>, con & escrito como &amp;.
tests/extract.ts
import type { Message } from './fixtures';

/** The whole body, both parts, with HTML entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&amp;/g, '&');

/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
  const hit = bodyOf(m).match(pattern);
  if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
  return hit[1];
}

/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
  const re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
  const hit = bodyOf(m).match(re);
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

El patrón está anclado a propósito en la redacción de tu propia plantilla. [0-9]{6} por sí solo encaja alegremente con un año, un precio o un número de pedido que resulte aparecer primero; code is ([0-9]{6}) encaja con tu código y nada más — y el día que alguien reescriba el correo, el test falla y te avisa, en lugar de pasar con el número equivocado.

Los enlaces se comparan con un fragmento de ruta que conoces — /confirm/, /reset/ — en lugar de con «la primera URL», porque un correo transaccional normalmente lleva cinco: el logo, la baja de suscripción, el centro de ayuda, el distintivo de la app store, y el que quieres.

Tres flujos, de principio a fin

Con el fixture y los extractores ya en su sitio, cada test se lee como la funcionalidad que ejercita. La espera, la consulta y el análisis están en otra parte, que es exactamente la razón de ponerlos ahí.

Registro con código de confirmación

tests/signup.spec.ts
import { test, expect } from './fixtures';
import { codeFrom } from './extract';

test('a new account confirms its email address', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();

  const message = await inbox.waitFor({ subjectContains: 'confirm' });

  await page.getByLabel('Confirmation code').fill(codeFrom(message));
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

Un enlace mágico que inicia la sesión del usuario

Nada que escribir: el test visita el enlace que lleva el mensaje, y comprueba dónde aterriza.

tests/magic-link.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a magic link signs the user in', async ({ page, inbox }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Email me a link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'sign in' });
  await page.goto(linkFrom(message, '/auth/magic/'));

  await expect(page).toHaveURL(/\/dashboard/);
});

Un restablecimiento de contraseña, y después un inicio de sesión con la contraseña nueva

El test de restablecimiento necesita un usuario que ya exista, y eso es trabajo del propio punto de entrada para pruebas de tu aplicación — un endpoint interno, un fixture de base de datos, una CLI — no del navegador. Después el flujo tiene la misma forma que los demás: pedir, esperar, seguir, comprobar.

tests/password-reset.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a password reset link changes the password', async ({ page, inbox, request }) => {
  // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
  await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'reset' });
  await page.goto(linkFrom(message, '/reset/'));
  await page.getByLabel('New password').fill('new-password-2');
  await page.getByRole('button', { name: 'Change password' }).click();

  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('new-password-2');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

Hacer que sobreviva a CI

Todo lo anterior funciona en un portátil. Esto es lo que solo se rompe cuando se ejecuta veinte veces al día en la máquina de otra persona.

SíntomaCausaSolución
Pasa en local, falla en CIEl ejecutor no puede acceder a la internet pública, o el tráfico de salida está filtrado.Permite grabmail.io por HTTPS. Nada más — ni puerto SMTP, ni tráfico entrante.
Falla la primera vez, pasa al reintentarTu remitente pone el correo en cola y el plazo es más corto que esa cola.Sube el plazo antes de tocar cualquier otra cosa. Sesenta segundos es un límite razonable para un correo transaccional.
429 en ráfagasVarios tests consultando una misma dirección, o todo el ejecutor superando las 1200 solicitudes por minuto.Una dirección por test — de eso se encarga el fixture. El límite del cliente es veinte buzones consultados una vez por segundo.
Build en verde, funcionalidad rotaUna dirección reutilizada sirvió un mensaje antiguo.Una dirección aleatoria por test. Esta es la que importa.
Inestable solo con varios workersDos tests compartiendo un buzón, o una comprobación sobre cuál mensaje es el más reciente.Una dirección nueva por test y un filtro subjectContains; nunca el mensaje más reciente a ciegas.
Funciona una semana, y luego nunca másUn fixture que guardó en caché un id de mensaje; aquí todo se elimina pasados 5 días.Los tests deben generar su propio correo en cada ejecución. Nada sobrevive 5 días.

No hay ningún secreto que guardar. Los dominios públicos no piden clave, ni cuenta, ni cabecera — si tu pipeline necesita una credencial para ejecutar estos tests, algo se ha entendido mal. El único ajuste que merece la pena anotar es el tiempo de espera, porque es lo único que Playwright trae mal configurado por defecto para un test que espera correo:

playwright.config.ts
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
  timeout: 120_000,
  expect: { timeout: 10_000 },
  fullyParallel: true,          // safe: every test has its own inbox
});

Un workflow de GitHub Actions que ejecuta esta batería de pruebas, con el plazo y la cuestión del tráfico de salida ya resueltos, está desarrollado en la guía de CI.

Si tu aplicación rechaza los dominios desechables

Algunos formularios de registro comprueban la dirección contra las listas públicas de dominios desechables y rechazan grabmail.io a primera vista. Eso es una característica de tu aplicación, no un fallo del test — y la solución no es debilitar la comprobación para el entorno de test. En su lugar, apunta un dominio que poseas a este servicio: un registro MX, sin cuenta, y cada dirección de ese dominio se convierte en un buzón que el mismo fixture puede leer cambiando una sola constante.

Convertir un dominio en un buzón catch-all es la configuración; cuentas de prueba ilimitadas en un dominio es cómo se ve eso en una batería de pruebas.

Antes de darlo por terminado

  • Una dirección distinta para cada test, desde el fixture — nunca una constante.
  • Un plazo de reloj real, y un fallo que indique el nombre de la dirección que esperaba.
  • Los 429 gestionados durmiendo el tiempo de Retry-After, no fallando.
  • El código o el enlace comparados contra tu propia redacción, no contra un patrón desnudo.
  • El tiempo de espera del test cómodamente por encima del plazo del correo.
  • Un filtro por asunto o remitente, para que gane el mensaje correcto cuando llegan dos.
  • Ninguna comprobación sobre lo rápido que llegó el correo — solo que llegó.

Esa es toda la disciplina. Todo lo demás sobre probar correo en Playwright es igual que probar cualquier otra cosa asíncrona. El mismo ayudante convertido en comandos de Cypress está en la guía de Cypress; las reglas de extracción por separado, para cualquier ejecutor, están en códigos OTP en tests automatizados.

Preguntas

¿Necesito una clave de API para leer el buzón desde Playwright?

No. Los dominios públicos no piden clave, ni cuenta, ni cabecera. Solo el fondo de pago de dominios que se mantienen fuera de las listas de bloqueo de correo desechable necesita una cabecera Authorization: Bearer, y eso es un producto aparte.

¿Pueden los tests ejecutarse en workers en paralelo?

Sí, y ese es el sentido de tener una dirección aleatoria por test: dos workers nunca pueden leer el correo del otro. El límite por cliente es de 1200 solicitudes por minuto, que son veinte buzones consultados una vez por segundo — de sobra para una batería de pruebas, y en cualquier caso el fixture nunca consulta más rápido que una vez por segundo.

¿Debería usar el fixture request de Playwright en lugar de fetch?

Cualquiera de los dos funciona. Aquí se usa fetch porque así el ayudante se ejecuta sin cambios en un script normal de Node, en un global setup, o en otro ejecutor. El request de Playwright añade trazado de las llamadas, algo que vale la pena si quieres que las consultas aparezcan en el trace viewer.

¿Qué pasa si el correo llega antes de que el test empiece a consultar?

Nada cambia. La primera consulta lo devuelve. Un buzón conserva lo que llega durante 5 días, haya alguien leyendo o no, así que un mensaje que llega durante el clic simplemente está ahí en la siguiente solicitud.

¿Es privado el buzón mientras lo usa el test?

No. Cualquiera que conozca la dirección puede leerlo, tanto en un dominio público como en el tuyo propio. Para una dirección aleatoria que existe durante once segundos y contiene un código desechable, eso es irrelevante; para un entorno de staging que envía correo real de clientes, es motivo suficiente para descartarlo — no apuntes uno aquí.

¿Cómo limpio después?

De forma opcional, con un DELETE sobre el mensaje, que es idempotente. Todo caduca pasados 5 días de todas formas, así que una ejecución que se salta la limpieza no cuesta nada — borrar solo hace que el siguiente fallo sea más fácil de leer.

Pruébalo mientras está reciente

Una dirección lleva un clic, sin cuenta y sin tarjeta. Todo lo de esta guía funciona con ella de inmediato.

Bienvenido de nuevo

Tus buzones y tus dominios, en un solo lugar.