Testes e CI

Testes de email com Playwright: ler um código real

Um teste em Playwright consegue preencher um formulário de cadastro em dois segundos e depois não faz ideia do que aconteceu a seguir, porque o próximo passo é um e-mail. Aqui está a fixture que o lê — uma mensagem real, de uma caixa de entrada real, sem chave de API — e os três hábitos que impedem o teste de ficar instável.

  • Intermediário
  • 20 min de leitura
Uma janela de navegador cinza com uma seta de cursor azul, um envelope azul entrando por uma fenda lateral e um cronômetro cinza na frente

Onde um teste em Playwright normalmente para

A maioria dos testes de cadastro termina na frase “confira sua caixa de entrada”. O formulário foi preenchido, o botão foi clicado, a página disse a coisa certa — e tudo o que acontece depois dessa frase é presumido. Se o e-mail saiu, se o código nele é o código que o servidor espera, se o link de confirmação abre uma página que funciona: tudo isso fica por conta da produção.

Fica assim porque o próximo passo é assíncrono e vive fora do navegador, e o Playwright não tem em que clicar. As três formas costumeiras de contornar isso provam, cada uma, algo diferente:

Mockar o serviço de envio de e-mail
Prova que o seu código chamou send(). Não prova 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 serviço 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 único custo é que o teste precisa esperar direito — o que é o assunto inteiro deste guia.

A API por trás disso é três endpoints sem chave, documentados na referência. Se você quer a disciplina geral antes das especificidades do Playwright, testando um fluxo de verificação de ponta a ponta cobre isso para qualquer runner; este aqui é a versão para Playwright, com a fixture que torna tudo mais agradável.

Uma fixture que dá a cada teste sua própria caixa de entrada

O test.extend do Playwright é o lugar certo para isso: uma caixa de entrada passa a ser algo que um teste pede pelo nome, como page, e o endereço é inventado do zero a cada vez. Nada precisa ser criado no servidor — uma caixa de entrada existe no momento em que a mensagem chega a ela — então a fixture é uma classe com um endereço aleatório dentro e três métodos pequenos.

tests/fixtures.ts
import { test as base, expect } from '@playwright/test';

const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';

export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export class Inbox {
  readonly address: string;

  /** A mailbox nothing else in this run, or any previous run, is using. */
  constructor(prefix = 'e2e') {
    this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
  }

  /** Block until a matching message arrives, or the deadline passes. */
  async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
    const deadline = Date.now() + (opts.timeoutMs ?? 60_000);

    while (Date.now() < deadline) {
      const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.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 ${this.address}`);

      const { messages } = (await res.json()) as { messages: Summary[] };
      const hit = messages.find(m =>
        (!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
        (!opts.fromContains    || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
      if (hit) return this.read(hit.id);

      await sleep(1000);                              // one read a second, never throttled
    }
    throw new Error(`no message for ${this.address} within the deadline`);
  }

  async read(id: string): Promise<Message> {
    const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
    if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
    return res.json() as Promise<Message>;
  }

  /** Optional: everything expires on its own after a few days. Idempotent. */
  async delete(id: string): Promise<void> {
    await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
  }
}

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    await use(new Inbox());
  },
});

export { expect };

Duas coisas nesse arquivo são propositais. O endereço é aleatório por teste, não por arquivo ou por execução, então workers paralelos nunca podem ler a mensagem um do outro. E waitFor retorna a mensagem completa em vez do resumo — na prática você sempre vai querer o corpo em seguida, e uma chamada a menos em cada teste soma bastante.

Esperando a mensagem sem um sleep

E-mail não é síncrono. Normalmente chega em dois ou três segundos e ocasionalmente leva vinte, e o jeito como o teste espera decide se a suíte pode ser confiável. As regras são curtas:

  • Um prazo, não uma contagem de tentativas. for (let i = 0; i < 30; i++) são trinta tentativas na velocidade que o loop tiver naquele momento — mais curto conforme a API fica mais rápida, mais longo conforme o seu remetente fica mais lento. Um prazo baseado em tempo real significa a mesma coisa em qualquer máquina.
  • Uma leitura por segundo. Esse é o ritmo documentado, e nesse ritmo você nunca esbarra em limite de taxa. Mais rápido que isso é recusado com 429 e um cabeçalho Retry-After, e fazer polling mais rápido não faria o e-mail chegar antes.
  • Nada de waitForTimeout. Um sleep fixo é curto demais em um dia lento ou longo demais em todos os outros dias. O loop para no momento em que a mensagem existe.
  • Filtre; não pegue a mensagem mais recente às cegas. Passe subjectContains ou fromContains. Quando um fluxo envia duas mensagens — uma de boas-vindas e uma com código — a mais recente nem sempre é a que você quer.

Os códigos de status que o loop vai encontrar, e o que fazer com cada um:

CódigoSignificaO que o loop faz
200A caixa de entrada foi lida. count pode ser 0 — uma caixa de entrada vazia nunca é um 404.Procure uma correspondência; se não houver nenhuma, espere um segundo e tente de novo.
400O endereço está malformado.Lance um erro. Repetir a tentativa não conserta um erro de digitação.
404O domínio não é hospedado aqui.Lance um erro, e confira o registro MX se for o seu próprio domínio.
429Mais de uma leitura por segundo para aquele endereço, ou mais de 1200 requisições por minuto vindas deste runner.Espere pelos segundos indicados em Retry-After e continue. Nunca falhe o teste por causa de um 429.

Extraindo o código, ou o link, da mensagem

A mensagem volta com as duas partes, e qual delas analisar depende do que a sua aplicação envia:

text
A parte em texto puro. Analise essa quando ela existir — sem marcação, e um código de seis dígitos é um código de seis dígitos.
html
A parte em HTML, ou null quando quem enviou mandou só texto. Links de confirmação frequentemente só existem aqui, dentro de um <a href>, com & escrito como &amp;.
tests/extract.ts
import type { Message } from './fixtures';

/** The whole body, both parts, with HTML 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 re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
  const hit = bodyOf(m).match(re);
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

O padrão é ancorado propositalmente no texto do seu próprio modelo. [0-9]{6} sozinho combina alegremente com um ano, um preço ou um número de pedido que apareceu primeiro; code is ([0-9]{6}) combina com o seu código e nada mais — e no dia em que alguém reformular o e-mail, o teste falha e te avisa, em vez de passar com o número errado.

Links são identificados por um fragmento de caminho que você conhece — /confirm/, /reset/ — em vez de “a primeira URL”, porque um e-mail transacional costuma carregar cinco: o logo, o cancelamento de inscrição, a central de ajuda, o selo da app store, e o que você quer.

Três fluxos, de ponta a ponta

Com a fixture e os extratores no lugar, cada teste se lê como a funcionalidade que ele exercita. A espera, o polling e a análise ficam em outro lugar, e essa é toda a razão de colocá-los lá.

Cadastro com código de confirmação

tests/signup.spec.ts
import { test, expect } from './fixtures';
import { codeFrom } from './extract';

test('a new account confirms its email address', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();

  const message = await inbox.waitFor({ subjectContains: 'confirm' });

  await page.getByLabel('Confirmation code').fill(codeFrom(message));
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

Um magic link que autentica o usuário

Nada para digitar: o teste visita o link que a mensagem carrega, e verifica onde ele chega.

tests/magic-link.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a magic link signs the user in', async ({ page, inbox }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Email me a link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'sign in' });
  await page.goto(linkFrom(message, '/auth/magic/'));

  await expect(page).toHaveURL(/\/dashboard/);
});

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

O teste de redefinição precisa de um usuário que já exista, o que é 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 — não para o navegador. Depois disso, o fluxo tem o mesmo formato dos outros: pedir, esperar, seguir, verificar.

tests/password-reset.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a password reset link changes the password', async ({ page, inbox, request }) => {
  // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
  await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'reset' });
  await page.goto(linkFrom(message, '/reset/'));
  await page.getByLabel('New password').fill('new-password-2');
  await page.getByRole('button', { name: 'Change password' }).click();

  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('new-password-2');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/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. Nada mais — nenhuma porta SMTP, nenhum tráfego de entrada.
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 testes fazendo polling em um endereço, ou o runner inteiro passando de 1200 requisições por minuto.Um endereço por teste — é isso que a fixture faz. O teto do cliente é vinte caixas de entrada consultadas uma vez por segundo.
Build verde, funcionalidade quebradaUm endereço reutilizado serviu uma mensagem antiga.Um endereço aleatório por teste. Esta é a que realmente importa.
Instável só com vários workersDois testes compartilhando uma caixa de entrada, ou uma verificação sobre qual mensagem é a mais recente.Um endereço novo por teste e um filtro subjectContains; nunca a mensagem mais recente às cegas.
Funciona por uma semana, depois nunca maisUma fixture que guardou em cache o id de uma mensagem; tudo aqui é apagado depois de 5 dias.Os testes 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 testes, algo foi mal entendido. A única configuração que vale a pena anotar é o timeout, porque é a única coisa que os padrões do Playwright erram para um teste que espera por e-mail:

playwright.config.ts
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
  timeout: 120_000,
  expect: { timeout: 10_000 },
  fullyParallel: true,          // safe: every test has its own inbox
});

Um workflow do GitHub Actions que roda essa suíte, com a questão do prazo e do tráfego de saída já resolvida, está detalhado 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 teste — 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 fixture 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 teste, vindo da fixture — nunca uma constante.
  • Um prazo baseado em tempo real, e uma falha que nomeia o endereço que estava esperando.
  • 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.
  • O timeout do teste confortavelmente acima do prazo do e-mail.
  • 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. Todo o resto sobre testar e-mail no Playwright é igual a testar qualquer outra coisa assíncrona. O mesmo helper na forma de comandos do Cypress está no guia do Cypress; as regras de extração por si só, para qualquer runner, estão em códigos OTP em testes automatizados.

Perguntas

Preciso de uma chave de API para ler a caixa de entrada a partir do Playwright?

Não. Os domínios públicos não exigem chave, conta nem cabeçalho. Só o conjunto pago de domínios que fica fora das listas de bloqueio de e-mail descartável precisa de um cabeçalho Authorization: Bearer, e esse é um produto separado.

Os testes podem rodar em workers paralelos?

Sim, e esse é o objetivo de um endereço aleatório por teste: dois workers nunca podem ler o e-mail um do outro. O teto por cliente é de 1200 requisições por minuto, o que dá vinte caixas de entrada consultadas uma vez por segundo — de sobra para uma suíte, e a fixture nunca faz polling mais rápido que uma vez por segundo de qualquer forma.

Devo usar a fixture request do Playwright em vez de fetch?

Os dois funcionam. O fetch é usado aqui porque assim o helper roda sem mudanças em um script Node simples, em um global setup, ou em outro runner. O request do Playwright adiciona rastreamento das chamadas, o que vale a pena se você quiser que o polling apareça no trace viewer.

E se o e-mail chegar antes de o teste 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 teste 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, 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.