Testes e CI

GitHub Actions: um job e2e que lê e-mails de verdade

O teste de cadastro que lê seu código de confirmação de uma caixa de entrada funciona em um notebook e depois encontra um runner de CI: nenhum segredo para montar, uma questão de tráfego de saída, um prazo que precisa caber no job, e uma caixa de entrada que precisa ser nova a cada execução e a cada retry. Aqui está o workflow, para Playwright e para pytest, com as partes que só importam em um runner.

  • Intermediário
  • 15 min de leitura
Três engrenagens cinza movendo uma esteira que leva um envelope azul até um portão cinza com uma lâmpada azul no topo

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.

.github/workflows/e2e.yml
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: 7

Trê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.

.github/workflows/e2e.yml
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.
  • grabmail.io:443 para 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.
em um runner protegido, libere exatamente isso
      - uses: step-security/harden-runner@v2
        with:
          egress-policy: block
          allowed-endpoints: >
            grabmail.io:443
            api.your-mail-provider.example:443
            registry.npmjs.org:443

Se 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:

quatro shards, cada um em um job separado
  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 }}/4

A 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 retries do Playwright
A mesma coisa, um nível abaixo: cada tentativa roda a fixture de novo. Não mova o endereço para um beforeAll para 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ógioDefinido ondeUm valor razoável
Prazo do e-mailDentro do helper (timeoutMs, timeout=)60 s. E-mail transacional chega em segundos; um minuto cobre uma fila lenta do provedor.
Timeout do testeplaywright.config.ts / --timeout120 s. Acima do prazo mais o trabalho do navegador ao redor dele.
Timeout do steptimeout-minutes no step, se houverGeralmente não definido; o limite do job já basta.
Timeout do jobtimeout-minutes no job20 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

um passo de limpeza que sempre roda
      - 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
          done

Torne-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.io liberada, 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á.

Experimente enquanto ainda está fresco

Um endereço leva um clique, sem conta e sem cartão. Tudo neste guia funciona nele imediatamente.

Bem-vindo de volta

As suas caixas e os seus domínios, num só lugar.