If your email tests run perfectly on your laptop and collapse the moment they hit CI, you are not alone. The usual response is to sprinkle sleep calls through the test code or bump the retry count until the build passes. That might quiet the noise for a day, but it does not fix the bug. It only hides it.
The real issue is how your test identifies which email to open.
The Shared Inbox Problem
On your local machine, you run one test at a time. One email arrives. You grab it. Simple.
CI is a different environment entirely. A single pull request might trigger four, eight, or sixteen parallel jobs. If they all share a test inbox—whether that is a Mailosaur server, a Mailtrap inbox, or a real account on a staging domain—they are all writing to the same bucket at the same time. Job A sends a password reset. Job B sends an invite. Job C retries a failed welcome flow. Meanwhile, background workers and delivery queues add jitter that you cannot control.
When every job reaches into that shared inbox and asks for the newest message with the subject "Reset your password," it becomes a race. The test that wins gets the right email. The test that loses clicks a link meant for another job, asserts against the wrong content, and fails with an error that looks like a timing problem. It is not a timing problem. It is an identity problem.
Why "Newest Message" Fails
The brittle pattern is easy to fall into because it feels intuitive:
- Trigger the user flow.
- Poll the inbox every few seconds.
- Open the most recent message that matches the subject line.
- Click the first link and run assertions.
This falls apart for several reasons beyond simple parallelism. A retry from a previous failed run can land late, suddenly becoming the newest message just as your current test polls. Background workers inside your application might queue two emails and deliver the second one before the first. Subject lines alone are weak identifiers; your staging application might send similar emails from different paths. Sorting by timestamp is worse than it looks because clock skew between the CI runner and the mail provider is real, and mail APIs often cache or batch their indexes.
Timestamps get fuzzy in busy environments. You need something direct.
What a Run Token Actually Is
A run token is nothing more than a unique string generated at the start of your test and injected into the email your application sends. It does not need to be user-facing, and it does not need to look elegant. It only needs to guarantee that you can prove this specific message belongs to this specific test execution.
Concrete examples work best. Before the test starts, generate a token such as:
- A UUID:
550e8400-e29b-41d4-a716-446655440001 - A build-scoped request ID:
req_ci_build_4821_a7f3 - An invite slug or metadata suffix:
signup-token-8k2m9n - A random hex string generated by the test runner:
test-run-a4f9c2d1
If you control the backend code, pass the token into the email context and render it somewhere in the body. If you are testing against a black-box application, see if the app already accepts a reference field you can hijack. If not, you can sometimes embed the token in the recipient local-part using plus addressing—testuser+a4f9c2d1@example.com—though that only works if your application preserves and echoes it back in the email.
The point is to stop matching on metadata the mail system already owns. Match on data your test owns.
The Reliable Pattern
Replace the "newest message" algorithm with a narrow, token-driven search:
- Generate the run token before you trigger any flow.
- Start the user action, ensuring the application will include the token in the outbound email.
- Poll the mail provider with filters constrained to that token. If the API supports body search, use it. If not, fetch candidate messages and grep their bodies client-side.
- Assert that the token exists in the message body before you touch any links, buttons, or verification codes.
- Only then extract the confirmation URL or code and continue.
This sequence matters. If you extract a link first and check the token second, you have already clicked the wrong email. The assertion is your gatekeeper.
Na prática, seu helper deve procurar por Subject:"Welcome to AppName" AND Body:"a4f9c2d1" em vez de Subject:"Welcome to AppName" sort:-received. Muitos serviços de teste de e-mail expõem APIs de busca que aceitam filtros de conteúdo do corpo. Use-os. Se você estiver trabalhando com um provedor mais simples, mantenha sua lógica de polling em um só lugar para que possa adicionar filtragem no lado do cliente de forma consistente em todos os testes.
Três Regras para Manter o Sistema Honesto
Um token de execução (run token) fixa a seleção, mas você ainda precisa de disciplina sobre como faz o polling e o que fazer quando algo dá errado.
Registre o estado da caixa de entrada em caso de falha. Quando um teste falhar, exiba o identificador da caixa de entrada, o assunto consultado, a janela exata de timestamp e quantos e-mails corresponderam aos seus critérios. Isso transforma um erro vago de "e-mail não encontrado" em uma história concreta. Se o job 7823 capturou uma mensagem de tentativa de um job 7821 porque ela chegou três segundos depois, seus logs devem tornar isso óbvio. Sem esse contexto, você culpará o tempo de execução (timing) e adicionará outro sleep.
Mantenha todo o polling de e-mail em um único arquivo helper. Não espalhe chamadas de setTimeout e cy.task por vinte arquivos de teste. Centralize a lógica que aguarda mensagens, tenta novamente a chamada de API e aplica o backoff. Se cada teste usar o mesmo helper, suas regras de filtragem permanecerão consistentes e, quando você melhorar a lógica de busca, todos os testes se beneficiarão. Isso também facilita a imposição da verificação do token; se o helper exigir um argumento de token, ninguém poderá recorrer acidentalmente à muleta da "última mensagem".
Cuidado com suas retentativas (retries). Retentativas de teste são comuns em CI, mas cada tentativa cria outro e-mail na caixa de entrada. Se o seu teste passar na terceira tentativa, você pode comemorar e seguir em frente. O que você não percebe é que as tentativas um e dois expuseram um bug real — uma condição de corrida (race condition), um envio duplicado ou um índice ausente — que as mensagens extras mascararam. Se você precisar usar retentativas, verifique se a caixa de entrada contém duplicatas inesperadas após uma falha. Melhor ainda, considere limpar a caixa de entrada ou usar um endereço exclusivo por job, se o seu provedor suportar caixas de entrada dinâmicas. As retentativas não devem se tornar uma estratégia para absorver uma lógica de seleção não confiável.
A Real Lição
Ordenar uma caixa de entrada por data e pegar o primeiro resultado não é testar. É adivinhar disfarçado de código. Um token de execução custa quase nada — uma variável de string, um parâmetro de filtro extra, talvez uma pequena mudança no template — e dá ao seu teste uma identidade determinística. Ele prova que a mensagem à sua frente pertence à execução que você está realizando agora.
Pare de adicionar sleeps e esperar que a rede se comporte. Gere um token, coloque-o no e-mail e pesquise por ele diretamente. Suas execuções de CI serão mais rápidas, seus logs serão legíveis e você finalmente confiará no que a suíte de e-mail está lhe dizendo.
