Por qué Cypress necesita una tarea para esto
Un spec de Cypress se ejecuta dentro del navegador, en la misma ventana que la página bajo prueba. Eso es lo que hace que cy.get y cy.contains sean tan directos, y también es la razón por la que el spec no puede simplemente hacer un bucle sobre una API HTTP durante un minuto: la cola de comandos no es lugar para un bucle while con una espera dentro, y una cadena de llamadas cy.request reintentadas es difícil de leer y más difícil aún de detener.
Las tres formas habituales de probar la mitad del flujo que es correo demuestran cada una algo distinto, y solo una demuestra lo que realmente publicaste:
- Un stub del mailer
- Demuestra que se llamó a
send()con los argumentos correctos. No dice 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 contenedor 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 coste es que el test tiene que esperar correctamente, y en Cypress el lugar adecuado para esperar es una tarea.
La API a la que llama la tarea son tres endpoints sin clave — la referencia es breve. La versión de esta disciplina independiente del ejecutor está en probar un flujo de verificación de principio a fin; la versión para Playwright, con un fixture en lugar de una tarea, está en la guía de Playwright.
La tarea: un bucle de consulta del lado de Node
Todo lo que tiene que esperar vive aquí, en setupNodeEvents. Es Node puro: fetch, un plazo, una lectura por segundo, y una rama para 429 que duerme en lugar de fallar. El spec nunca ve nada de esto.
import { defineConfig } from 'cypress';
const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
type Args = { address: string; subjectContains?: string; fromContains?: string; timeoutMs?: number };
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
taskTimeout: 90_000, // above the mail deadline below, always
setupNodeEvents(on) {
on('task', {
/** Poll a mailbox until a matching message arrives, or the deadline passes. */
async waitForMail({ address, subjectContains, fromContains, timeoutMs = 60_000 }: Args) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(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 ${address}`);
const { messages } = (await res.json()) as { messages: { id: string; from: string; subject: string }[] };
const hit = messages.find(m =>
(!subjectContains || m.subject.toLowerCase().includes(subjectContains.toLowerCase())) &&
(!fromContains || m.from.toLowerCase().includes(fromContains.toLowerCase())));
if (hit) {
const full = await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`);
if (!full.ok) throw new Error(`GET /message answered ${full.status}`);
return full.json(); // the whole message, both parts
}
await sleep(1000); // one read a second, never throttled
}
return null; // "not yet" is an answer, not an error
},
});
},
},
});En ese archivo hay dos decisiones que merece la pena señalar. La tarea devuelve el mensaje completo, no el resumen, porque lo siguiente que quiere cada spec es el cuerpo, y una segunda llamada a la tarea para conseguirlo es ruido. Y devuelve null al llegar el plazo en lugar de lanzar una excepción: «todavía no hay mensaje» es una respuesta legítima que puede dar una tarea, y el comando de más abajo es donde eso se convierte en un fallo con un mensaje útil.
Dos comandos personalizados y dos extractores
Los comandos son deliberadamente ligeros. freshAddress inventa un buzón; waitForMail llama a la tarea con un tiempo de espera cómodamente por encima del plazo y comprueba la respuesta. Los extractores son funciones normales, porque son puro trabajo con cadenas de texto y un comando de Cypress solo haría más difícil probarlos por unidad.
export type Message = {
id: string; from: string; to: string; subject: string; date: string;
text: string | null; html: string | null;
};
type WaitOpts = { subjectContains?: string; fromContains?: string; timeoutMs?: number };
declare global {
namespace Cypress {
interface Chainable {
/** A mailbox nothing else in this run, or any previous run, is using. */
freshAddress(prefix?: string): Chainable<string>;
/** Block until a matching message arrives. Fails the test at the deadline. */
waitForMail(address: string, opts?: WaitOpts): Chainable<Message>;
}
}
}
Cypress.Commands.add('freshAddress', (prefix = 'cy') =>
cy.wrap(`${prefix}-${Math.random().toString(36).slice(2, 10)}@grabmail.io`, { log: false }));
Cypress.Commands.add('waitForMail', (address, opts = {}) =>
cy.task<Message | null>('waitForMail', { address, ...opts }, { timeout: (opts.timeoutMs ?? 60_000) + 10_000 })
.then(m => {
expect(m, `a message for ${address}`).not.to.be.null;
return cy.wrap(m as Message, { log: false });
}));
/** The whole body, both parts, with the entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&/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 hit = bodyOf(m).match(new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`));
if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
return hit[0];
}Fíjate en el timeout propio del comando: es el plazo de la tarea más diez segundos, así que la tarea siempre llega a tiempo de dar su respuesta. Sin él, el tiempo de espera de tarea de sesenta segundos que trae Cypress por defecto compite con el plazo de correo de sesenta segundos y gana por unos milisegundos, y el fallo culpa a la tarea.
Tres specs, de principio a fin
Con la tarea y los comandos ya en su sitio, cada spec se lee como la funcionalidad que ejercita. La espera y el análisis están en otra parte, que es exactamente el sentido de ponerlos ahí.
Registro con código de confirmación
import { codeFrom } from '../support/commands';
describe('sign-up', () => {
it('confirms the address with the emailed code', () => {
cy.freshAddress().then(address => {
cy.visit('/signup');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('correct-horse-battery-staple');
cy.contains('button', 'Create account').click();
cy.contains('Check your inbox').should('be.visible');
cy.waitForMail(address, { subjectContains: 'confirm' }).then(message => {
cy.get('input[name="code"]').type(codeFrom(message));
cy.contains('button', 'Confirm').click();
cy.contains('h1', 'Welcome').should('be.visible');
});
});
});
});Un inicio de sesión que pide un código de un solo uso por correo
El usuario tiene que existir de antemano, 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 — al que se llega con cy.request, no a través del navegador.
import { codeFrom } from '../support/commands';
describe('login with an emailed one-time code', () => {
it('asks for the code and accepts it', () => {
cy.freshAddress().then(address => {
// Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
cy.request('POST', '/internal/test/users', { email: address, password: 'hunter2hunter2', otpByEmail: true });
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('hunter2hunter2');
cy.contains('button', 'Log in').click();
cy.contains('Enter the code we emailed you').should('be.visible');
cy.waitForMail(address, { subjectContains: 'code' }).then(message => {
cy.get('input[name="otp"]').type(codeFrom(message, /code is\s*([0-9]{6})/i));
cy.contains('button', 'Continue').click();
cy.url().should('include', '/dashboard');
});
});
});
});Un restablecimiento de contraseña, y después un inicio de sesión con la contraseña nueva
El enlace de restablecimiento se sigue con un simple cy.visit cuando apunta al mismo origen que baseUrl. Si tu aplicación envía a los usuarios a otro origen para la página de restablecimiento — un subdominio de autenticación, por ejemplo — envuelve los pasos de esa página en cy.origin(); la extracción del enlace no cambia.
import { linkFrom } from '../support/commands';
describe('password reset', () => {
it('changes the password through the emailed link', () => {
cy.freshAddress().then(address => {
cy.request('POST', '/internal/test/users', { email: address, password: 'old-password-1' });
cy.visit('/forgot-password');
cy.get('input[name="email"]').type(address);
cy.contains('button', 'Send reset link').click();
cy.waitForMail(address, { subjectContains: 'reset' }).then(message => {
cy.visit(linkFrom(message, '/reset/')); // same origin as baseUrl: a plain visit
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Change password').click();
});
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Log in').click();
cy.url().should('include', '/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íntoma | Causa | Solución |
|---|---|---|
| Pasa en local, falla en CI | El ejecutor no puede acceder a la internet pública, o el tráfico de salida está filtrado. | Permite grabmail.io por HTTPS desde el lado de Node. Nada más — ni puerto SMTP, ni tráfico entrante. |
| «cy.task timed out» sin ninguna mención al correo | taskTimeout (60 s por defecto) está por debajo del plazo del correo. | Configura taskTimeout por encima del plazo en la configuración, y pasa el tiempo de espera por llamada que el comando ya calcula. |
| Falla la primera vez, pasa al reintentar | Tu 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áfagas | Varios specs consultando una misma dirección, o el ejecutor superando las 1200 solicitudes por minuto. | Una dirección por spec. El límite del cliente es veinte buzones consultados una vez por segundo. |
| Build en verde, funcionalidad rota | Una dirección reutilizada sirvió un mensaje antiguo. | freshAddress en cada cuerpo de test. Esta es la que importa. |
| Funciona una semana, y luego nunca más | Un fixture guardó en caché un id de mensaje; aquí todo se elimina pasados 5 días. | Los specs 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 specs, algo se ha entendido mal. Un workflow de GitHub Actions que ejecuta una batería de pruebas como esta, con la cuestión del tráfico de salida ya resuelta, está 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 spec — 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 la misma tarea 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 spec, inventada en el cuerpo del test — nunca una constante.
- La espera en una tarea con un plazo de reloj real;
nullal llegar el plazo, nuncaundefined. taskTimeouty el tiempo de espera del comando, ambos por encima del plazo del correo.- Los
429gestionados durmiendo el tiempo deRetry-After, no fallando. - El código o el enlace comparados contra tu propia redacción, no contra un patrón desnudo.
- 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. Las reglas de extracción por separado, para cualquier ejecutor, están en códigos OTP en tests automatizados.
Preguntas
¿Podría usar cy.request en un bucle en lugar de una tarea?
Puedes: cy.request también se ejecuta del lado de Node, así que no está sujeto a CORS, y una función recursiva que vuelve a pedir hasta encontrar una coincidencia o llegar a un plazo funciona. Solo que es más difícil de leer y más difícil de detener que una tarea con un bucle while dentro, y la tarea mantiene el spec libre de lógica de reintentos.
¿Necesito una clave de API o una variable de entorno de Cypress?
No. Los dominios públicos no piden clave, ni cuenta, ni cabecera, así que no hay nada que poner en cypress.env.json ni en los secretos de CI. Solo el fondo de pago de dominios que se mantienen fuera de las listas de bloqueo de correo desechable usa un token Bearer, y eso es un producto aparte.
¿Funciona esto con los reintentos de test y la paralelización de Cypress?
Sí, precisamente porque la dirección se inventa dentro del cuerpo del test: cada reintento y cada máquina en paralelo obtiene su propio buzón. El límite por cliente de 1200 solicitudes por minuto son veinte buzones consultados una vez por segundo, algo a lo que una ejecución de Cypress nunca se acerca.
¿Qué pasa si el correo llega antes de que la tarea 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 spec?
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 desde la tarea, 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.


