Testes e CI

Testes de email com Cypress: cadastro, OTP e redefinição

O Cypress roda seu teste no navegador, e o navegador não consegue ler uma caixa de entrada. A solução costumeira é uma task do lado do Node que faz o polling — aqui está ela, contra uma caixa de entrada descartável real e sem chave de API, além dos dois comandos customizados que fazem um spec de cadastro se ler como a funcionalidade que ele testa.

  • Intermediário
  • 19 min de leitura
Um braço mecânico cinza baixando um envelope azul sobre uma bancada com três cubos cinza marcados com um sinal de visto azul

Por que o Cypress precisa de uma task para isso

Um spec do Cypress roda dentro do navegador, na mesma janela que a página sob teste. É isso que torna cy.get e cy.contains tão diretos, e é também por isso que o spec não pode simplesmente rodar em loop sobre uma API HTTP por um minuto: a fila de comandos não é lugar para um loop while com um sleep dentro, e uma cadeia de chamadas cy.request repetidas é difícil de ler e mais difícil ainda de parar.

As três formas costumeiras de testar a metade do fluxo que envolve e-mail provam, cada uma, algo diferente, e só uma delas prova o que você realmente colocou no ar:

Fazer stub do serviço de envio de e-mail
Prova que send() foi chamado com os argumentos certos. Não diz nada sobre o template, o link, ou o provedor que rejeitou a mensagem.
Um coletor SMTP local (Mailpit, MailHog, smtp4dev)
Prova que uma mensagem bem formada saiu da aplicação. Mais um container na CI, e nada do que só acontece na internet pública — uma consulta MX real, um provedor real, um destinatário real — acontece aqui.
Uma caixa de entrada descartável real
Prova que a mensagem saiu da aplicação, atravessou a internet, foi aceita por um servidor de e-mail real e carrega um código que funciona. O custo é que o teste precisa esperar direito, e no Cypress o lugar certo para esperar é uma task.

A API que a task chama é três endpoints sem chave — a referência é curta. A versão independente de runner dessa disciplina está em testando um fluxo de verificação de ponta a ponta; a versão para Playwright, com uma fixture em vez de uma task, está no guia do Playwright.

A task: um loop de polling do lado do Node

Tudo o que precisa esperar vive aqui, em setupNodeEvents. É Node puro: fetch, um prazo, uma leitura por segundo, e um branch para 429 que espera em vez de falhar. O spec nunca vê nada disso.

cypress.config.ts
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
        },
      });
    },
  },
});

Duas decisões nesse arquivo merecem ser explicadas. A task retorna a mensagem completa, não o resumo, porque a próxima coisa que todo spec quer é o corpo, e uma segunda chamada de task para isso é ruído. E ela retorna null ao atingir o prazo em vez de lançar um erro: “ainda não há mensagem” é uma resposta legítima para uma task dar, e o comando abaixo é onde isso vira uma falha com uma mensagem útil.

Dois comandos customizados e dois extratores

Os comandos são propositalmente enxutos. freshAddress inventa uma caixa de entrada; waitForMail chama a task com um timeout confortavelmente acima do prazo e verifica a resposta. Os extratores são funções simples, porque são puro trabalho com strings, e um comando do Cypress só tornaria mais difícil testá-los isoladamente.

cypress/support/commands.ts
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(/&amp;/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];
}

Note o timeout próprio do comando: é o prazo da task mais dez segundos, para que a task sempre tenha a chance de dar sua resposta. Sem isso, o timeout padrão de sessenta segundos da task do Cypress compete com o prazo de sessenta segundos do e-mail e vence por alguns milissegundos, e a falha acusa a task.

Três specs, de ponta a ponta

Com a task e os comandos no lugar, cada spec se lê como a funcionalidade que ele exercita. A espera e a análise ficam em outro lugar, e esse é todo o sentido de colocá-las lá.

Cadastro com código de confirmação

cypress/e2e/signup.cy.ts
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');
      });
    });
  });
});

Um login que pede um código de uso único enviado por e-mail

O usuário precisa existir primeiro, e isso é tarefa para o próprio ponto de acesso de teste da sua aplicação — um endpoint interno, uma fixture de banco de dados, uma CLI — alcançado com cy.request, não pelo navegador.

cypress/e2e/otp-login.cy.ts
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');
      });
    });
  });
});

Uma redefinição de senha, depois um login com a senha nova

O link de redefinição é seguido com um cy.visit simples quando ele aponta para a mesma origem que baseUrl. Se a sua aplicação manda os usuários para outra origem para a página de redefinição — um subdomínio de autenticação, por exemplo — envolva os passos nessa página com cy.origin(); a extração do link não muda.

cypress/e2e/password-reset.cy.ts
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');
    });
  });
});

Fazendo isso sobreviver à CI

Tudo acima funciona em um notebook. Estas são as coisas que só quebram quando isso roda vinte vezes por dia na máquina de outra pessoa.

SintomaCausaCorreção
Passa localmente, falha na CIO runner não consegue alcançar a internet pública, ou o tráfego de saída é filtrado.Libere grabmail.io via HTTPS a partir do lado do Node. Nada mais — nenhuma porta SMTP, nenhum tráfego de entrada.
“cy.task timed out” sem nenhuma palavra sobre e-mailtaskTimeout (60 s por padrão) está abaixo do prazo do e-mail.Defina taskTimeout acima do prazo na configuração, e passe o timeout por chamada que o comando já calcula.
Falha na primeira vez, passa ao repetirSeu remetente enfileira e-mails e o prazo é mais curto que a fila.Aumente o prazo antes de mexer em qualquer outra coisa. Sessenta segundos é um teto razoável para um e-mail transacional.
429 em rajadasVários specs fazendo polling em um endereço, ou o runner passando de 1200 requisições por minuto.Um endereço por spec. O teto do cliente é vinte caixas de entrada consultadas uma vez por segundo.
Build verde, funcionalidade quebradaUm endereço reutilizado serviu uma mensagem antiga.freshAddress em todo corpo de teste. Esta é a que realmente importa.
Funciona por uma semana, depois nunca maisUma fixture guardou em cache o id de uma mensagem; tudo aqui é apagado depois de 5 dias.Os specs precisam disparar o próprio e-mail a cada execução. Nada sobrevive 5 dias.

Não há segredo para armazenar: os domínios públicos não exigem chave, conta nem cabeçalho. Se a sua pipeline precisa de uma credencial para rodar esses specs, algo foi mal entendido. Um workflow do GitHub Actions que roda uma suíte como essa, com a questão do tráfego de saída já resolvida, está no guia de CI.

Se a sua aplicação recusa domínios descartáveis

Alguns formulários de cadastro conferem o endereço contra as listas públicas de domínios descartáveis e recusam grabmail.io de cara. Isso é uma funcionalidade da sua aplicação, não um defeito do spec — e a correção não é enfraquecer a verificação para o ambiente de teste. Em vez disso, aponte um domínio seu para este serviço: um registro MX, sem conta, e todo endereço nele vira uma caixa de entrada que a mesma task consegue ler mudando uma única constante.

Transformar um domínio em uma caixa de entrada catch-all é a configuração; contas de teste ilimitadas em um domínio é como isso fica em uma suíte de testes.

Antes de considerar concluído

  • Um endereço diferente para cada spec, inventado no corpo do teste — nunca uma constante.
  • A espera em uma task com um prazo baseado em tempo real; null ao atingir o prazo, nunca undefined.
  • taskTimeout e o timeout do comando, os dois acima do prazo do e-mail.
  • 429 tratado esperando pelo tempo de Retry-After, não falhando o teste.
  • O código ou link conferido contra o seu próprio texto, não um padrão genérico.
  • Um filtro por assunto ou remetente, para que a mensagem certa vença quando duas chegarem.
  • Nenhuma verificação sobre a velocidade com que o e-mail chegou — só que ele chegou.

Essa é toda a disciplina. As regras de extração por si só, para qualquer runner, estão em códigos OTP em testes automatizados.

Perguntas

Eu poderia usar cy.request em um loop em vez de uma task?

Pode: cy.request também roda do lado do Node, então não está sujeito a CORS, e uma função recursiva que refaz a requisição até haver uma correspondência ou o prazo acabar funciona. Só que é mais difícil de ler e mais difícil de parar do que uma task com um loop while dentro, e a task mantém o spec livre de lógica de repetição.

Preciso de uma chave de API ou de uma variável de ambiente do Cypress?

Não. Os domínios públicos não exigem chave, conta nem cabeçalho, então não há nada para colocar em cypress.env.json ou nos secrets da CI. Só o conjunto pago de domínios mantidos fora das listas de bloqueio de e-mail descartável usa um token bearer, e esse é um produto separado.

Isso funciona com os retries de teste e a paralelização do Cypress?

Sim, exatamente porque o endereço é inventado dentro do corpo do teste: cada retry e cada máquina paralela recebe sua própria caixa de entrada. O teto por cliente de 1200 requisições por minuto equivale a vinte caixas de entrada consultadas uma vez por segundo, algo que uma execução do Cypress nunca chega perto de atingir.

E se o e-mail chegar antes de a task começar a fazer polling?

Nada muda. A primeira consulta já retorna a mensagem. Uma caixa de entrada guarda o que chega por 5 dias, alguém estando lendo ou não, então uma mensagem que chega durante o clique simplesmente está lá na próxima requisição.

A caixa de entrada é privada enquanto o spec a usa?

Não. Qualquer um que saiba o endereço pode lê-la, em um domínio público e também no seu. Para um endereço aleatório que existe por onze segundos e guarda um único código descartável, isso é irrelevante; para um ambiente de staging que envia e-mail real de clientes, isso é desqualificante — não aponte um para cá.

Como eu limpo tudo depois?

Opcionalmente, com um DELETE na mensagem a partir da task, que é idempotente. De qualquer forma, tudo expira depois de 5 dias, então uma execução que pula a limpeza não custa nada — apagar só torna a próxima falha mais fácil de entender.

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.