O que muda em um runner
O teste em si não muda. O que muda é tudo ao redor dele, e cada um desses pontos tem uma resposta específica, não um dar de ombros:
- Não há segredo nenhum para montar
- Ler uma caixa de entrada pública não exige chave, conta nem cabeçalho, então o lado da caixa de entrada não adiciona nada a
secrets. A única credencial no job é a que a sua aplicação já precisa para enviar e-mail — SendGrid, Postmark, SES, qualquer que seja — e ela pertence à sua aplicação, não ao teste. - O runner precisa alcançar a internet
- Saída HTTPS para
grabmail.io, e saída para o que quer que o seu serviço de envio use. Runners hospedados pelo GitHub permitem os dois por padrão; um runner auto-hospedado atrás de um filtro de tráfego de saída precisa de uma regra adicionada. - Execuções se sobrepõem
- Dois pull requests, quatro shards, o retry de um job instável — várias cópias do mesmo teste leem e-mail ao mesmo tempo. Um endereço compartilhado deixaria que elas lessem o código umas das outras; um endereço novo por teste torna essa classe inteira de problema impossível.
- O tempo é medido
- Um teste que espera sessenta segundos por e-mail está tudo bem. Um job que espera sessenta segundos em cada um de quarenta testes são quarenta minutos de runner cobrados. As esperas precisam ter um limite, e a suíte precisa ser dividida em shards assim que crescer.
O código de teste que esses workflows rodam é o mesmo do guia do Playwright ou do guia do Python: um endereço novo, uma espera com prazo, um extrator ancorado no seu template. Nada nele é específico de CI, e esse é o objetivo — as partes específicas do runner ficam todas no arquivo de workflow.
O workflow, para Playwright
Um job só. Ele inicia a sua aplicação com um serviço de envio de e-mail real, espera ela responder, roda a suíte, e mantém o relatório só quando algo falhou.
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: 7Três linhas carregam o peso principal. timeout-minutes: 20 é o limite externo dentro do qual tudo o mais se encaixa. A aplicação é iniciada com suas credenciais reais de envio de e-mail, porque um teste que lê e-mail de verdade precisa que e-mail de verdade seja enviado. E o relatório só é enviado como artifact em caso de falha, com uma retenção curta — uma execução que passou não tem nada que valha a pena guardar.
O workflow, para pytest
O mesmo formato com o toolchain do Python: inicie a aplicação, espere pelo endpoint de health check dela, rode a suíte com um timeout por teste acima do prazo do e-mail, mantenha o relatório JUnit em caso de falha.
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 vem do plugin pytest-timeout e é o teto por teste; o prazo do e-mail dentro do helper é de sessenta segundos, então um teste que espera por uma mensagem e depois faz algum trabalho no navegador ainda cabe. O loop de health check é escrito por extenso em vez de importado como uma action porque são oito linhas e não há nada nelas para dar errado.
A única regra de rede
Ler uma caixa de entrada é uma requisição HTTPS de saída do runner para grabmail.io. Essa é toda a superfície de rede envolvida:
- Nenhuma entrada. Nada se conecta ao runner. Não há webhook para receber, nenhum servidor SMTP para rodar, nenhuma porta para expor.
- Nenhum SMTP a partir do runner. O e-mail é enviado pela sua aplicação através do provedor dela, pela API ou pelo endpoint SMTP desse provedor — do mesmo jeito que acontece em produção. O runner nunca fala SMTP diretamente.
- Só
grabmail.io:443para adicionar em um runner com uma lista de permissões de saída — além do seu provedor de e-mail e do seu registro de pacotes, que o job já precisava de qualquer forma.
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
grabmail.io:443
api.your-mail-provider.example:443
registry.npmjs.org:443Se a suíte passa localmente e falha na CI com um erro de conexão vindo do helper, essa regra é a primeira coisa a checar, e quase sempre é a resposta inteira. Um runner auto-hospedado em uma rede corporativa costuma ter o tráfego de saída HTTPS filtrado por hostname; o hostname a liberar é o da API, e a requisição é HTTPS simples na porta 443.
Shards, jobs em matriz e retries
Assim que a suíte ficar lenta o suficiente para justificar shards, divida-a. O Playwright divide uma execução entre jobs com --shard, e como cada teste abre sua própria caixa de entrada, os shards não precisam de nada um do outro:
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 }}/4A mesma propriedade cobre as outras duas formas de um teste acabar rodando duas vezes ao mesmo tempo:
- Dois pull requests ao mesmo tempo
- Dois jobs, dois conjuntos de endereços aleatórios, nenhuma sobreposição. O teto por cliente na API é de 1200 requisições por minuto, o que equivale a vinte caixas de entrada consultadas uma vez por segundo — um job fazendo polling em uma caixa de entrada por vez fica bem longe disso.
- Um job repetido
- Um retry roda o corpo do teste de novo, o que inventa um novo endereço de novo. A caixa de entrada antiga ainda guarda a mensagem antiga por 5 dias, e nada a lê — o retry nunca a vê.
- Os próprios
retriesdo Playwright - A mesma coisa, um nível abaixo: cada tentativa roda a fixture de novo. Não mova o endereço para um
beforeAllpara economizar tempo; esse é exatamente o compartilhamento que deixa uma tentativa ler o código da tentativa anterior.
Timeouts que se encaixam dentro do job
Existem quatro relógios e eles precisam se encaixar, o mais interno sendo o mais curto. Quando isso não acontece, a falha é reportada pelo relógio errado e aponta para a causa errada.
| Relógio | Definido onde | Um valor razoável |
|---|---|---|
| Prazo do e-mail | Dentro do helper (timeoutMs, timeout=) | 60 s. E-mail transacional chega em segundos; um minuto cobre uma fila lenta do provedor. |
| Timeout do teste | playwright.config.ts / --timeout | 120 s. Acima do prazo mais o trabalho do navegador ao redor dele. |
| Timeout do step | timeout-minutes no step, se houver | Geralmente não definido; o limite do job já basta. |
| Timeout do job | timeout-minutes no job | 20 min. Suficiente para instalar, iniciar, rodar a suíte e enviar o artifact; baixo o bastante para que uma aplicação travada não seja cobrada por uma hora. |
O sintoma de um encaixe quebrado é específico: um teste que espera sessenta segundos dentro do timeout padrão de trinta segundos do Playwright morre aos trinta segundos com uma mensagem sobre o teste, sempre, e não diz nada sobre o e-mail. Defina o timeout do teste primeiro, depois tudo o que vem por fora dele.
Lendo uma falha
Uma execução que falhou deveria te dizer qual das três coisas aconteceu — o e-mail nunca chegou, o e-mail errado chegou, ou o código nele estava errado — sem precisar rodar nada de novo. Quatro hábitos tornam isso verdade:
- Registre o endereço no log. A mensagem de falha do helper nomeia a caixa de entrada que esperou. Imprima-o mais uma vez no topo do teste para que ele fique no log do job mesmo quando a verificação estiver em outro lugar.
- Abra a caixa de entrada manualmente. As mensagens ficam por 5 dias, então
/inbox/<address>neste site mostra exatamente o que o runner viu — ou não viu — pelo resto da semana. Essa é a coisa mais útil de uma caixa de entrada real em comparação com uma mockada. - Mantenha o relatório em caso de falha. O trace do Playwright mostra o clique que deveria ter enviado o e-mail; o arquivo JUnit mostra qual teste e por quanto tempo ele esperou.
- Confira o status do serviço antes de culpar o teste. A página de status é sondada de fora a cada dois minutos; se o recebimento de e-mail estava fora do ar na hora da execução, a falha foi real, e não sua.
Limpando, opcionalmente
Tudo expira depois de 5 dias, alguém apagando ou não, então uma execução que pula a limpeza não custa nada. Ainda assim, apagar o que a execução leu vale um passo, porque a próxima falha é então lida contra uma caixa de entrada genuinamente vazia. É idempotente — apagar duas vezes ainda responde 200 — então isso nunca pode falhar um build sozinho:
- 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
doneTorne-o if: always() e nunca o torne obrigatório: uma limpeza que falha deveria ser um aviso no log, não um build vermelho.
Antes de considerar concluído
- Nenhuma credencial de caixa de entrada em
secrets; só a própria chave de envio de e-mail da sua aplicação. - Saída HTTPS para
grabmail.ioliberada, e nada de entrada. - Um endereço novo por teste, inventado no corpo do teste — seguro sob shards e retries.
- Os quatro timeouts encaixados: prazo < teste < step < job.
- O relatório enviado como artifact em caso de falha, com o endereço no log.
- Limpeza como um passo que sempre roda e nunca é obrigatório.
Isso é tudo o que o runner adiciona. A disciplina de teste por baixo disso — prazo, endereço novo, padrão ancorado — está em testando um fluxo de verificação de ponta a ponta, e as regras de extração por si só estão em códigos OTP em testes automatizados.
Perguntas
Preciso adicionar um secret do GrabMail ao repositório?
Não. Os domínios públicos não exigem chave, conta nem cabeçalho, então não há nada para adicionar a secrets. A única credencial no workflow é a que a sua aplicação usa para enviar e-mail, que ela precisaria para funcionar de qualquer forma.
Isso funciona em um repositório privado ou em um runner auto-hospedado?
Sim. O runner faz requisições HTTPS de saída para grabmail.io e nada mais; onde você hospeda o runner é irrelevante. Em um runner auto-hospedado atrás de um filtro de tráfego de saída, libere esse hostname na porta 443.
Jobs concorrentes vão atingir o limite de taxa?
Na prática, não. O limite por endereço é uma leitura por segundo, que o helper respeita, e o teto por cliente é de 1200 requisições por minuto — vinte caixas de entrada consultadas uma vez por segundo, a partir de um runner. Vários runners são vários clientes. Um 429 é respondido com Retry-After, e o helper espera esse tempo em vez de falhar.
Posso rodar isso em uma agenda, como uma verificação sintética do cadastro em produção?
Sim, e é um bom uso para isso: um workflow on: schedule que se cadastra com um endereço novo a cada hora e lê o código prova todo o caminho do e-mail em produção, provedor incluído. Mantenha o prefixo do endereço reconhecível para que os cadastros sejam fáceis de eliminar do seu lado.
E se a minha aplicação recusar domínios descartáveis?
Aponte um domínio seu para o serviço — um registro MX, sem conta — e use esse domínio na fixture. A configuração leva poucos minutos e contas de teste ilimitadas em um domínio mostra o padrão em uma suíte.
Alguma coisa na caixa de entrada é privada?
Não. Qualquer um que saiba um endereço pode lê-lo, em um domínio público e também no seu. Para um endereço aleatório guardando um único código descartável, isso é irrelevante; para um ambiente de staging que envia dados reais de clientes, isso é desqualificante — não aponte um para cá.


