«Ese test no se puede automatizar.»

Me lo dijeron cuando propuse automatizar un flujo que pedía un código enviado por email. El usuario hace algo en la plataforma, le llega un correo con seis dígitos, los escribe y el sistema continúa. La parte del correo, me explicaron, la tiene que hacer una persona.

Lo automaticé con Playwright, TypeScript e IMAP. La verificación pasó de unos cuatro minutos a mano a menos de treinta segundos, sin nadie mirando la bandeja.

Hoy ese test está en test.skip, igual que los otros tres que leen el correo, desde fines de abril. El código no se rompió: el administrador del Workspace deshabilitó el acceso IMAP, y desde ese día la conexión falla con Invalid credentials aunque la contraseña de aplicación sea válida.

Por eso esta guía tiene dos mitades. La primera es el helper: cómo leer el código desde Playwright sin que el test se vuelva inestable. La segunda es la decisión que me habría ahorrado el skip: por qué canal leer el correo, y de quién dependes en cada uno.

Qué necesitas

Un proyecto de Playwright con TypeScript (yo usé Playwright 1.63), Node 20 o superior y una cuenta de correo de prueba con IMAP habilitado. Si no tienes esa cuenta y el entorno de test es de tu equipo, salta a la sección de Mailpit: ahí no necesitas ninguna. Todo el código de esta guía lo corrí antes de publicarlo, el helper contra un servidor IMAP local y la parte de Mailpit contra un Mailpit real; al final te cuento cómo.

Por qué el correo parece imposible: vive fuera del navegador

Playwright maneja el navegador. El correo no está en el navegador: está en un servidor que tu test no ve. Cuando lo haces a mano, abres otra pestaña, entras a tu bandeja, copias el código y vuelves. El test tiene que hacer lo mismo, pero sin pestaña: conectarse a la bandeja igual que lo hace la app de correo de tu celular.

Esa conexión se llama IMAP. Es el protocolo con el que Outlook, la app Mail del iPhone o Thunderbird le preguntan al servidor qué correos hay. Si tu test habla IMAP, puede abrir la bandeja, leer los últimos mensajes y sacar el código.

«No se puede automatizar» casi nunca es un límite técnico. Es un límite de conocimiento: no saber que existe esa segunda puerta.

Paso 1 — La cuenta de prueba: contraseña de aplicación y alias

Tres decisiones antes de escribir una línea de código.

Una cuenta dedicada al equipo, no la tuya. El test va a leer esa bandeja cientos de veces. Que sea una cuenta creada para eso, con la contraseña guardada como secreto del pipeline y no en la laptop de alguien.

Contraseña de aplicación, no la contraseña normal. Desde el 14 de marzo de 2025, las cuentas de Google Workspace ya no aceptan IMAP con usuario y contraseña. Google dejó dos salidas: OAuth o las contraseñas de aplicación. Una contraseña de aplicación es una clave de 16 caracteres que generas en la cuenta (necesitas tener activada la verificación en dos pasos) y que sirve solo para esto. Si se filtra, la revocas sin tocar la contraseña real.

Alias con +. Gmail entrega qa+recuperacion@tudominio.com en la bandeja de qa@tudominio.com, pero conserva el alias en el destinatario. Eso te da un filtro sin costo: cada flujo escribe a su propio alias, y el test solo lee los correos dirigidos a él. Más adelante vas a ver por qué este detalle es el que te deja correr en paralelo.

El .env, que nunca se sube al repositorio:

Terminal window
IMAP_USER=qa@tudominio.com
IMAP_APP_PASSWORD=xxxxxxxxxxxxxxxx

En CI, las mismas dos variables van como secretos del pipeline.

Paso 2 — Instalar imapflow y mailparser

Terminal window
npm install -D imapflow mailparser @types/mailparser

imapflow se conecta a la bandeja por IMAP. mailparser convierte el correo crudo en algo que puedes leer: remitente, destinatario, fecha y texto.

Una aclaración. El helper que uso en el trabajo está escrito con imap, la librería clásica. Su última versión, la 0.8.19, es de diciembre de 2016. Para esta guía lo reescribí con imapflow, que está mantenida (la 2.0.2 salió en septiembre de 2026) y funciona con async/await en lugar de callbacks anidados. La estrategia es la misma de mi helper original; lo que cambia es que se lee de arriba abajo.

Paso 3 — Las cuatro decisiones que hacen confiable el helper

Leer un correo por IMAP se aprende en diez minutos. Lo que lleva tiempo es que el test no falle una vez de cada diez. Estas cuatro decisiones son las que separan las dos cosas.

1. Marcar el inicio antes del click, con 30 segundos de margen

El test tiene que saber qué correos son nuevos. Por eso, antes de disparar el envío, el helper cuenta cuántos mensajes hay en la bandeja y anota la hora. Si lo marcas después del click, un correo rápido llega antes de la marca y el test lo ignora, esperando uno que ya llegó.

La hora lleva 30 segundos de margen hacia atrás porque el reloj del servidor que envía y el de la máquina que corre el test no siempre coinciden. Si el servidor va unos segundos atrasado, el correo correcto parece anterior a la marca y el test lo descarta.

2. Pedir los mensajes nuevos, no buscarlos

IMAP tiene un comando de búsqueda, y es lo primero que uno usa. En Gmail me dio problemas: el correo ya estaba en la bandeja y la búsqueda todavía no lo devolvía. Mi helper dejó de buscar y pasó a pedir directamente los mensajes nuevos por su posición en la bandeja, que es inmediato. Después filtra él mismo.

3. Filtrar por remitente, alias y fecha, los tres

Una bandeja de pruebas tiene ruido: correos viejos, correos de otros tests, promociones. Cada filtro descarta un tipo de ruido distinto:

  • El remitente descarta todo lo que no manda tu aplicación, incluido el cupón de descuento con seis dígitos que tu regex también reconoce.
  • El alias descarta los códigos de otros tests que corren al mismo tiempo.
  • La fecha descarta un código viejo que aparece tarde, por ejemplo el de una corrida anterior que el proveedor entregó con demora.

Cuando probé el helper, quité cada filtro por separado. En los tres casos el test devolvió un código equivocado.

4. Nunca imprimir el código en el log

El log de CI lo lee cualquiera con acceso al pipeline, y queda guardado. Un código de verificación, y todavía más un link de recuperación, es una llave de un solo uso. El helper puede avisar que encontró el código; lo que no hace es decir cuál es.

Es la misma regla que se aplica al código de producción. Alejandro Lafourcade la deja sin matices en su artículo sobre logging:

Passwords, tokens, API keys. Jamás. Ni en DEBUG.

— Alejandro Lafourcade · System.out.println en producción, la confesión que nadie hace

Un código de verificación entra en esa lista, aunque viva en un test.

El helper completo: EmailInbox

Así quedan las cuatro decisiones en código. Guárdalo en support/email-inbox.ts o donde vivan tus utilidades:

import { expect } from '@playwright/test';
import { ImapFlow } from 'imapflow';
import { simpleParser, type AddressObject } from 'mailparser';
type InboxConfig = {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
};
type CodeQuery = {
to: string; // la dirección exacta, con alias incluido
from: string; // remitente esperado, ej. "no-reply@tuapp.com"
pattern?: RegExp; // por defecto, seis dígitos sueltos
};
const CLOCK_SKEW_MS = 30_000;
const MAX_NEW = 50;
export class EmailInbox {
private since = 0;
private baseline = 0;
constructor(private readonly config: InboxConfig) {}
static fromEnv(): EmailInbox {
const user = process.env.IMAP_USER;
const pass = process.env.IMAP_APP_PASSWORD;
if (!user || !pass) {
throw new Error('Faltan IMAP_USER o IMAP_APP_PASSWORD en el .env');
}
return new EmailInbox({
host: process.env.IMAP_HOST ?? 'imap.gmail.com',
port: Number(process.env.IMAP_PORT ?? 993),
secure: process.env.IMAP_SECURE !== 'false',
user,
pass,
});
}
/** Llamar ANTES de la acción que dispara el correo. */
async markStart(): Promise<void> {
this.since = Date.now() - CLOCK_SKEW_MS;
this.baseline = await this.withInbox(async (client) => mailboxSize(client));
}
/** Mira la bandeja cada 5 s hasta que llega el código o se acaba el tiempo. */
async waitForCode(query: CodeQuery, timeout = 90_000): Promise<string> {
let code = '';
await expect
.poll(async () => (code = (await this.findCode(query)) ?? ''), {
message: `el código de verificación llega a ${query.to}`,
timeout,
intervals: [5_000],
})
.not.toBe('');
return code;
}
/**
* Una sola mirada a la bandeja. Devuelve el código o null.
* Nunca lanza: un error de red es "todavía no", no un fallo del test.
*/
async findCode({ to, from, pattern = /\b(\d{6})\b/ }: CodeQuery): Promise<string | null> {
try {
return await this.withInbox(async (client) => {
const total = mailboxSize(client);
if (total <= this.baseline) return null;
const start = Math.max(this.baseline + 1, total - MAX_NEW + 1);
const raws: Buffer[] = [];
for await (const msg of client.fetch(`${start}:${total}`, { source: true })) {
if (msg.source) raws.push(msg.source);
}
for (const raw of raws.reverse()) {
const mail = await simpleParser(raw);
if (!addresses(mail.from).includes(from.toLowerCase())) continue;
if (!addresses(mail.to).includes(to.toLowerCase())) continue;
if ((mail.date?.getTime() ?? 0) < this.since) continue;
const match = (mail.text ?? '').match(pattern);
if (match) return match[1] ?? match[0];
}
return null;
});
} catch (error) {
console.log(`[email] la bandeja no respondió, reintento: ${(error as Error).message}`);
return null;
}
}
private async withInbox<T>(work: (client: ImapFlow) => Promise<T>): Promise<T> {
const { host, port, secure, user, pass } = this.config;
const client = new ImapFlow({ host, port, secure, auth: { user, pass }, logger: false });
await client.connect();
try {
const lock = await client.getMailboxLock('INBOX', { readOnly: true });
try {
return await work(client);
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
}
function mailboxSize(client: ImapFlow): number {
return client.mailbox ? client.mailbox.exists : 0;
}
function addresses(field: AddressObject | AddressObject[] | undefined): string[] {
const list = Array.isArray(field) ? field : field ? [field] : [];
return list.flatMap((obj) => obj.value.map((a) => (a.address ?? '').toLowerCase()));
}

Tres detalles que no se ven a primera vista:

  • readOnly: true abre la bandeja en modo lectura. El test no marca nada como leído ni mueve nada: si alguien del equipo abre la cuenta para depurar, la encuentra como la dejó.
  • La validación del certificado TLS queda activada. Mi helper original tenía rejectUnauthorized: false, que la desactiva, y es la clase de línea que se copia de un foro para que algo conecte y después nadie la vuelve a mirar. Contra imap.gmail.com no hace falta.
  • markStart sí puede fallar, y está bien que falle. Si no llegas a la bandeja antes del click, prefieres enterarte ahí y no después de noventa segundos esperando un código.

Paso 4 — El test: marcar, disparar y esperar el código

Con el helper, el test se lee como el flujo que describe:

import { test, expect } from '@playwright/test';
import { EmailInbox } from '../support/email-inbox';
const SENDER = 'no-reply@tuapp.com';
test('recupera la contraseña con el código que llega por email', async ({ page }) => {
test.setTimeout(120_000); // el correo puede tardar más que los 30 s por defecto
const inbox = EmailInbox.fromEnv();
const alias = 'qa+recuperacion@tudominio.com';
await page.goto('/recuperar-contrasena');
await page.getByLabel('Email').fill(alias);
await inbox.markStart(); // antes del click, siempre
await page.getByRole('button', { name: 'Enviar código' }).click();
const code = await inbox.waitForCode({ to: alias, from: SENDER });
await page.getByLabel('Código de verificación').fill(code);
await page.getByRole('button', { name: 'Continuar' }).click();
await expect(page.getByRole('heading', { name: 'Crea tu nueva contraseña' })).toBeVisible();
});

Cambia los selectores, el remitente y la URL por los de tu aplicación. Lo que no cambia es el orden: markStart, después el click, después waitForCode.

waitForCode usa expect.poll, que es la forma de Playwright de decir «repite esta pregunta hasta que la respuesta sea la que espero». Tiene dos ventajas sobre un while con setTimeout escrito a mano: el tiempo de espera respeta el del test, y si el código no llega, el reporte dice qué se esperaba (el código de verificación llega a qa+recuperacion@...) en lugar de un timeout mudo.

Fíjate en el test.setTimeout(120_000). Playwright le da 30 segundos a cada test por defecto, y el helper espera el correo hasta 90. Sin subir el timeout del test, Playwright corta antes de que el helper termine de esperar.

Por qué findCode nunca lanza un error

expect.poll reintenta cuando el valor todavía no cumple. Pero si la función que le pasas lanza un error, el poll termina en ese mismo instante y no vuelve a intentar. Lo comprobé con un test de control: la función lanzó en la primera llamada y el poll se cortó ahí, con diez segundos de margen sin usar. Un corte de red de un segundo contra el servidor de correo te tiraría el test entero. Por eso findCode convierte cualquier error de conexión en «todavía no» y deja que el poll vuelva a mirar.

Paso 5 — Cómo diseñar la prueba cuando el código es real

El helper resuelve cómo leer el correo. Estas tres decisiones son de diseño de la prueba, y son las que más veo saltarse.

No pruebes el código incorrecto en el mismo flujo que usa el correcto. El código que llega es real, y el sistema lo invalida después de varios intentos fallidos. Si tu test escribe primero un código equivocado para ver el mensaje de error y después el bueno, el bueno puede no servir ya. En mi suite, el flujo por teléfono usa un código fijo para las cuentas de prueba; el de email no, y por eso ese test no prueba ni el código vacío ni el incorrecto. Esas validaciones van en un test aparte.

Paralelo sí, con un alias por flujo. El conteo de mensajes te dice que llegó algo nuevo; el alias te dice que es tuyo. Mi suite original corre estos tests en serie, con mode: 'serial', y su filtro acepta también la dirección base de la cuenta: en paralelo, dos tests se podrían robar el código. Con un alias por flujo, dos workers esperan su código a la vez sin pisarse. En registro, donde cada corrida crea un usuario nuevo, el alias puede llevar un timestamp: qa+registro-${Date.now()}@tudominio.com. En recuperación de contraseña el usuario tiene que existir, así que el alias es fijo por flujo.

Si llega un link en vez de un código, cambia la extracción, no el helper. En lugar del regex de seis dígitos, buscas el href del botón dentro de mail.html. Dos trampas que me encontré: el & de la URL viene escrito como &amp; dentro del HTML y hay que devolverlo a & antes de navegar, y si el proveedor de envío tiene activado el seguimiento de clics, el link no apunta a tu app sino a una redirección del proveedor. Funciona igual al abrirlo, pero no le hagas assertions al dominio. Y lo mismo que con el código: el link no se imprime.

Hoy mi test está en skip: la parte que no depende de ti

Los cuatro archivos de specs que leen el correo tienen el mismo comentario arriba del test.skip. La conexión falla con Invalid credentials, la contraseña de aplicación es válida, y la causa no está en el código: el administrador del Workspace deshabilitó IMAP por política.

Ese interruptor existe y es legítimo. En la consola de administración de Google (Apps → Google Workspace → Gmail → End User Access) se puede apagar IMAP para toda la organización o para una unidad. Cuando está apagado, cualquier cliente IMAP que intente entrar falla al iniciar sesión. El error dice credenciales, y las credenciales están bien.

El comentario deja escritas las dos salidas: pedirle al admin que habilite IMAP para la cuenta de prueba, o migrar el helper a la Gmail API con OAuth, que estimé en tres o cuatro horas de trabajo. Ninguna de las dos está hecha todavía. Un test en skip no pone nada en rojo, así que nada obliga a decidir.

El resultado, en términos que entiende cualquiera: desde fines de abril, la recuperación de contraseña y el registro con verificación por correo no tienen cobertura automatizada. Son justo los flujos que, si se rompen, dejan a un usuario afuera de su cuenta sin forma de volver a entrar.

Si leíste cómo audité mi propia suite, esto es lo que esconde el conteo de skipped: tests que alguien escribió bien, que siguen en el repositorio, y que no están probando nada.

Al elegir por dónde leer el correo, entonces, hay dos preguntas. Si funciona, y quién puede apagarlo. La segunda es la que decide cuánto dura el test.

IMAP, Mailpit, Gmail API o código fijo: cómo elegir el canal

CanalQuién puede romperloSirve cuandoCosto de entrada
MailpitNadie fuera del equipoEl SMTP del entorno es tuyoUn contenedor
IMAPEl admin del correoEl entorno manda correo realUna cuenta de prueba
Gmail APIEl admin y Google CloudIMAP está cerradoUnas horas
Código fijoEl equipo de devEl backend lo ofreceCasi nada

Mailpit: cuando el entorno de test es de tu equipo

Si controlas el entorno donde corren los tests, no necesitas una bandeja real. Mailpit es un servidor de correo falso: la aplicación le manda los correos a él en lugar de al proveedor real, y tú los lees por una API HTTP. Sin credenciales, sin políticas de nadie, y el correo aparece en cuanto la aplicación lo envía.

Terminal window
docker run -d --name mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit

El puerto 1025 recibe el correo (ahí apuntas el SMTP de la aplicación en ese entorno) y el 8025 sirve la interfaz web y la API. La lectura del código cabe en una función, usando el request de Playwright:

import type { APIRequestContext } from '@playwright/test';
const MAILPIT = process.env.MAILPIT_URL ?? 'http://localhost:8025';
/** Una sola mirada a Mailpit. Devuelve el código o null. */
export async function findCodeInMailpit(
request: APIRequestContext,
to: string,
pattern = /\b(\d{6})\b/,
): Promise<string | null> {
const search = await request.get(`${MAILPIT}/api/v1/search`, {
params: { query: `to:"${to}"`, limit: 1 },
});
if (!search.ok()) return null;
const { messages } = await search.json();
if (!messages?.length) return null;
const message = await request.get(`${MAILPIT}/api/v1/message/${messages[0].ID}`);
if (!message.ok()) return null;
const { Text } = await message.json();
return Text?.match(pattern)?.[1] ?? null;
}

Se usa con el mismo expect.poll, y con intervalos más cortos porque aquí no hay que esperar a un proveedor externo. Ojo con los nombres de los campos: la API devuelve ID y Text con mayúscula.

Su límite: Mailpit no sirve para las pruebas contra producción. Ahí el correo sale por el proveedor real, y la única forma de verlo es leer una bandeja real.

IMAP: cuando el correo es real

Es lo que construiste en esta guía. Sirve en staging y en producción, con cualquier proveedor que exponga IMAP. Su riesgo es el que ya viste: depende de una política que decide alguien fuera de tu equipo. Si vas por este camino, deja escrito quién administra la cuenta de prueba y avísale que un test depende de ella.

Y deja el cambio de canal barato desde el principio. Si tus tests llaman a EmailInbox directamente en cada spec, migrar a Mailpit o a la Gmail API es tocar todos los specs. Si lo llaman a través de una interfaz propia (algo como findCode({ to, from })), cambias una implementación y los tests ni se enteran. Es el mismo principio que Alejandro Lafourcade explica en Cambiar de proveedor sin tocar el core , con un caso que parece escrito para esta guía: un equipo que tuvo que pasar sus notificaciones de email a SMS.

Gmail API: cuando IMAP está cerrado

La API de Gmail con OAuth no pasa por el interruptor de IMAP. A cambio necesitas un proyecto en Google Cloud, credenciales OAuth y un token que se renueva; en Workspace, además, un admin que autorice la aplicación. Cambias una dependencia por otra, y conviene saberlo antes de presentarlo como la solución definitiva.

Código fijo: cuando el backend te lo da

Algunos equipos configuran un código fijo para las cuentas de prueba fuera de producción, como el flujo por teléfono de mi suite. Es rápido y estable, pero prueba menos:

Código fijo: sabes que la pantalla acepta un código. No sabes si el correo sale, si llega ni si el template trae el código.
Código leído del correo: pruebas el recorrido completo que hace el usuario, del click hasta su bandeja y de vuelta.

El código fijo es ideal para las validaciones negativas del paso 5. Para reemplazar el flujo completo, se queda corto.

Lo que llevas a quien decide: el diagnóstico listo para reenviar

Si tienes tests de correo en skip, o estás por elegir el canal, esto es lo que le mandas a quien firma. Con mi caso se ve así:

Flujo: recuperación de contraseña y registro con código por email.
Cobertura automatizada: ninguna desde el 29 de abril (4 archivos en skip).
Causa: IMAP deshabilitado por política del Workspace. No es un fallo del test.
Riesgo: si el correo deja de salir o el código deja de validar, lo reporta un usuario que no puede entrar.
Opciones:
A. Habilitar IMAP solo para la cuenta de prueba (un pedido al admin).
B. Migrar el helper a la Gmail API con OAuth (3 a 4 horas).
C. Mailpit en staging para lo que no es producción (configurar el SMTP del entorno).
Pido: elegir A, B o C antes del próximo release.

Quien firma no necesita entender IMAP para elegir entre A, B y C. Necesita saber qué flujo quedó sin red, desde cuándo y cuánto cuesta cada salida.

Cómo probé el código de esta guía

No publiqué el helper de memoria. Lo corrí contra hoodiecrow-imap, un servidor IMAP en memoria pensado para tests, con seis casos: el código nuevo entre correos de ruido, cada filtro por separado, el código que nunca llega, la bandeja vacía, la bandeja caída y el control de expect.poll que lanza un error. La función de Mailpit la corrí contra un Mailpit real, con dos correos a alias distintos.

Después hice mutation testing a mano: borré el filtro de remitente, después el de alias, después el de fecha, y corrí la suite cada vez. Los tres cambios pusieron el test en rojo. Si un filtro se puede borrar sin que falle nada, ese filtro no está probado.

Si hoy tienes un test de correo en skip, abre el comentario que lo justifica y fíjate si dice quién tiene que mover algo para reactivarlo. Si no lo dice, empieza por ahí.