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.
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
429e um cabeçalhoRetry-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
subjectContainsoufromContains. 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ódigo | Significa | O que o loop faz |
|---|---|---|
200 | A 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. |
400 | O endereço está malformado. | Lance um erro. Repetir a tentativa não conserta um erro de digitação. |
404 | O domínio não é hospedado aqui. | Lance um erro, e confira o registro MX se for o seu próprio domínio. |
429 | Mais 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
nullquando quem enviou mandou só texto. Links de confirmação frequentemente só existem aqui, dentro de um<a href>, com&escrito como&.
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(/&/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
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.
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.
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.
| Sintoma | Causa | Correção |
|---|---|---|
| Passa localmente, falha na CI | O 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 repetir | Seu 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 rajadas | Vá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 quebrada | Um 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 workers | Dois 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 mais | Uma 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 — 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.
429tratado esperando pelo tempo deRetry-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.


