Qué cambia en un ejecutor
El test en sí no cambia. Lo que cambia es todo lo que lo rodea, y cada uno de estos puntos tiene una respuesta concreta, no un encogimiento de hombros:
- No hay ningún secreto que montar
- Leer un buzón público no necesita clave, ni cuenta, ni cabecera, así que la parte del buzón no añade nada a
secrets. La única credencial del job es la que tu aplicación ya necesita para enviar correo — SendGrid, Postmark, SES, la que sea — y pertenece a tu aplicación, no al test. - El ejecutor tiene que llegar a internet
- Salida HTTPS hacia
grabmail.io, y salida hacia lo que use tu remitente de correo. Los ejecutores hospedados por GitHub permiten las dos cosas por defecto; un ejecutor autohospedado detrás de un filtro de salida necesita añadir una regla. - Las ejecuciones se solapan
- Dos pull requests, cuatro shards, el reintento de un job inestable — varias copias del mismo test leen correo al mismo tiempo. Una dirección compartida les dejaría leer los códigos del otro; una dirección nueva por test hace imposible toda esa categoría de problema.
- El tiempo se factura
- Un test que espera sesenta segundos por el correo está bien. Un job que espera sesenta segundos en cada uno de cuarenta tests son cuarenta minutos de ejecutor facturado. Las esperas tienen que estar acotadas, y la batería de pruebas tiene que dividirse en shards en cuanto crezca.
El código de test que ejecutan estos workflows es el de la guía de Playwright o la guía de Python: una dirección nueva, una espera con un plazo, un extractor anclado en tu plantilla. Nada de eso es específico de CI, que es justo el punto — las partes específicas del ejecutor viven todas en el archivo del workflow.
El workflow, para Playwright
Un solo job. Arranca tu aplicación con un remitente de correo real, espera a que responda, ejecuta la batería de pruebas, y conserva el informe solo cuando algo falló.
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20 # the whole job, comfortably above every wait inside it
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
# Your application, started the way it runs in staging: a REAL outbound
# mailer. Its credentials are YOUR secret; the mailbox side needs none.
- name: Start the application
run: npm run start:test &
env:
MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
APP_URL: http://localhost:3000
- name: Wait for the application
run: npx wait-on --timeout 60000 http://localhost:3000/health
- name: Run the suite
run: npx playwright test
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7Tres líneas cargan con el peso. timeout-minutes: 20 es el límite exterior dentro del que se anida todo lo demás. La aplicación se arranca con sus credenciales reales de remitente de correo, porque un test que lee correo real necesita que se envíe correo real. Y el informe se sube solo cuando falla, con una retención corta — una ejecución que pasa no tiene nada que merezca la pena conservar.
El workflow, para pytest
La misma forma con el conjunto de herramientas de Python: arranca la aplicación, espera a su endpoint de salud, ejecuta la batería de pruebas con un tiempo de espera por test por encima del plazo del correo, conserva el informe JUnit cuando falla.
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -r requirements.txt -r requirements-test.txt
- name: Start the application
run: python -m app.server &
env:
MAILER_API_KEY: ${{ secrets.MAILER_API_KEY }}
APP_URL: http://localhost:8000
- name: Wait for the application
run: |
for i in $(seq 1 60); do
curl -sf http://localhost:8000/health && exit 0
sleep 1
done
echo "application did not come up" >&2; exit 1
- name: Run the suite
run: pytest tests/e2e -q --timeout=120 --junitxml=report.xml
env:
BASE_URL: http://localhost:8000
- uses: actions/upload-artifact@v4
if: failure()
with:
name: pytest-report
path: report.xml--timeout=120 viene del plugin pytest-timeout y es el límite por test; el plazo de correo dentro del ayudante es de sesenta segundos, así que un test que espera un mensaje y después hace algo de trabajo con el navegador todavía encaja. El bucle de comprobación de salud está escrito directamente en lugar de importado como una action porque son ocho líneas y no hay nada en ellas que se pueda estropear.
La única regla de red
Leer un buzón es una solicitud HTTPS de salida desde el ejecutor hacia grabmail.io. Esa es toda la huella de red:
- Nada de entrada. Nada se conecta al ejecutor. No hay ningún webhook que recibir, ningún servidor SMTP que ejecutar, ningún puerto que exponer.
- Nada de SMTP desde el ejecutor. El correo lo envía tu aplicación a través de su proveedor, mediante la API o el endpoint SMTP de ese proveedor — de la misma forma que lo hace en producción. El ejecutor nunca habla SMTP directamente.
- Solo hay que añadir
grabmail.io:443en un ejecutor con una lista de permitidos de salida — más tu proveedor de correo y tu registro de paquetes, que el job ya necesitaba de todas formas.
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
grabmail.io:443
api.your-mail-provider.example:443
registry.npmjs.org:443Si la batería de pruebas pasa en local y falla en CI con un error de conexión del ayudante, esta regla es lo primero que hay que comprobar, y casi siempre es toda la respuesta. Un ejecutor autohospedado en una red corporativa normalmente tendrá la salida HTTPS filtrada por hostname; el hostname que hay que permitir es el de la API, y la solicitud es HTTPS normal por el puerto 443.
Shards, jobs de matriz y reintentos
En cuanto la batería de pruebas sea lo bastante lenta como para dividirla en shards, divídela. Playwright reparte una ejecución entre varios jobs con --shard, y como cada test abre su propio buzón, los shards no necesitan nada el uno del otro:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
# ... the same steps as above, then:
- run: npx playwright test --shard=${{ matrix.shard }}/4La misma propiedad cubre las otras dos formas en que un test acaba ejecutándose dos veces a la vez:
- Dos pull requests al mismo tiempo
- Dos jobs, dos conjuntos de direcciones aleatorias, sin solapamiento. El límite por cliente en la API es de 1200 solicitudes por minuto, que son veinte buzones consultados una vez por segundo — un job que consulta un buzón a la vez ni se acerca a eso.
- Un job reintentado
- Un reintento vuelve a ejecutar el cuerpo del test, que vuelve a inventar una dirección nueva. El buzón antiguo sigue conservando el mensaje antiguo durante 5 días, y nada lo lee — el reintento nunca lo ve.
- Los propios
retriesde Playwright - Lo mismo, un nivel más abajo: cada intento vuelve a ejecutar el fixture. No muevas la dirección a un
beforeAllpara ahorrar tiempo; eso es exactamente el tipo de compartición que deja que un intento lea el código del intento anterior.
Tiempos de espera que encajan dentro del job
Hay cuatro relojes y tienen que anidarse, el más corto por dentro. Cuando no lo hacen, el fallo lo reporta el que no toca, y señala a la causa equivocada.
| Reloj | Dónde se configura | Un valor razonable |
|---|---|---|
| Plazo del correo | Dentro del ayudante (timeoutMs, timeout=) | 60 s. El correo transaccional llega en segundos; un minuto cubre una cola lenta del proveedor. |
| Tiempo de espera del test | playwright.config.ts / --timeout | 120 s. Por encima del plazo más el trabajo del navegador alrededor. |
| Tiempo de espera del paso | timeout-minutes en el paso, si lo hay | Normalmente sin definir; el límite del job es suficiente. |
| Tiempo de espera del job | timeout-minutes en el job | 20 min. Suficiente para instalar, arrancar, la batería de pruebas y la subida; bajo para que una aplicación colgada no facture una hora. |
El síntoma de un anidamiento roto es concreto: un test que espera sesenta segundos dentro del tiempo de espera de treinta segundos que trae Playwright por defecto muere a los treinta segundos con un mensaje sobre el test, siempre, y no dice nada sobre el correo. Configura primero el tiempo de espera del test, y después todo lo demás hacia fuera.
Leer un fallo
Una ejecución fallida debería decirte cuál de estas tres cosas pasó — el correo nunca llegó, llegó el correo equivocado, o el código que traía era incorrecto — sin volver a ejecutar nada. Cuatro hábitos consiguen eso:
- Registra la dirección en el log. El mensaje de fallo del ayudante indica el buzón que esperaba. Imprímela una vez más al principio del test para que esté en el log del job incluso cuando la comprobación falla en otro sitio.
- Abre el buzón a mano. Los mensajes se conservan 5 días, así que
/inbox/<address>en este sitio muestra exactamente lo que vio el ejecutor — o no vio — durante el resto de la semana. Esto es lo más útil, con diferencia, de tener un buzón real en lugar de uno simulado. - Conserva el informe cuando falle. El trace de Playwright muestra el clic que debería haber enviado el correo; el archivo JUnit muestra qué test fue y cuánto esperó.
- Comprueba el estado del servicio antes de culpar al test. La página de estado se sondea desde fuera cada dos minutos; si el correo entrante estaba caído en el momento de la ejecución, el fallo fue real y no tuyo.
Limpiar, de forma opcional
Todo caduca pasados 5 días, lo elimine alguien o no, así que una ejecución que se salta la limpieza no cuesta nada. Aun así, borrar lo que leyó la ejecución merece un paso, porque así el siguiente fallo se lee contra un buzón genuinamente vacío. Es idempotente — borrar dos veces sigue respondiendo 200 — así que nunca puede hacer fallar un build por sí solo:
- name: Delete what the run read
if: always()
run: |
for addr in $(cat .e2e-addresses 2>/dev/null); do
curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$addr" \
| jq -r '.messages[].id' \
| xargs -r -I{} curl -sX DELETE -G "https://grabmail.io/api/v1/message/{}" --data-urlencode "mailbox=$addr" -o /dev/null
donePonlo como if: always() y nunca lo hagas obligatorio: una limpieza que falla debería ser un aviso en un log, no un build en rojo.
Antes de darlo por terminado
- Ninguna credencial de buzón en
secrets; solo la clave de remitente de correo de tu propia aplicación. - Salida HTTPS hacia
grabmail.iopermitida, y nada de entrada. - Una dirección nueva por test, inventada en el cuerpo del test — segura bajo shards y reintentos.
- Los cuatro tiempos de espera anidados: plazo < test < paso < job.
- El informe subido cuando falla, con la dirección en el log.
- La limpieza como un paso que siempre se ejecuta y nunca es obligatorio.
Eso es todo lo que añade el ejecutor. La disciplina de test que hay debajo — plazo, dirección nueva, patrón anclado — está en probar un flujo de verificación de principio a fin, y las reglas de extracción por separado están en códigos OTP en tests automatizados.
Preguntas
¿Necesito añadir un secreto de GrabMail al repositorio?
No. Los dominios públicos no piden clave, ni cuenta, ni cabecera, así que no hay nada que añadir a secrets. La única credencial del workflow es la que usa tu aplicación para enviar correo, que necesitaría de todas formas para funcionar.
¿Funciona esto en un repositorio privado o en un ejecutor autohospedado?
Sí. El ejecutor hace solicitudes HTTPS de salida hacia grabmail.io y nada más; dónde alojes el ejecutor es irrelevante. En un ejecutor autohospedado detrás de un filtro de salida, permite ese hostname por el puerto 443.
¿Van a chocar los jobs simultáneos con el límite de solicitudes?
En la práctica, no. El límite por dirección es una lectura por segundo, que el ayudante respeta, y el límite por cliente es de 1200 solicitudes por minuto — veinte buzones consultados una vez por segundo, desde un solo ejecutor. Varios ejecutores son varios clientes. Un 429 se responde con Retry-After, y el ayudante duerme ese tiempo en lugar de fallar.
¿Puedo ejecutar esto de forma programada, como una comprobación sintética del registro en producción?
Sí, y es un buen uso: un workflow on: schedule que se registra con una dirección nueva cada hora y lee el código demuestra toda la ruta de correo de producción, proveedor incluido. Mantén el prefijo de la dirección reconocible para que los registros sean fáciles de purgar por tu lado.
¿Qué pasa si mi aplicación rechaza los dominios desechables?
Apunta un dominio que poseas al servicio — un registro MX, sin cuenta — y usa ese dominio en el fixture. La configuración lleva unos minutos, y cuentas de prueba ilimitadas en un dominio muestra el patrón en una batería de pruebas.
¿Hay algo privado en el buzón?
No. Cualquiera que conozca una dirección puede leerla, tanto en un dominio público como en el tuyo propio. Para una dirección aleatoria que contiene un código desechable, eso es irrelevante; para un entorno de staging que envía datos reales de clientes, es motivo suficiente para descartarlo — no apuntes uno aquí.


