DEV Community

Silviu Technology
Silviu Technology

Posted on

A Better Inbox Contract for Cypress CI

Cypress email tests usually look simple right up until CI starts running them in parallel. The browser action passes, the API call looks healthy, and then the inbox assertion turns into a vague timeout with almost no evidence. I have seen teams throw bigger waits at that problem for weeks. It rarely helps for long.

What helped more was treating inbox polling like a contract instead of a helper. Once every test had to declare who owned the inbox, when the email was triggered, and what message shape counted as valid, failures got much less mysterious. That matters whether you are testing against a disposable mail address or a private sandbox inbox. The point is not the provider. The point is keeping the assertion explorable when CI gets weird.

Why Cypress email checks become noisy in CI

Most flaky email tests fail for one of these reasons:

  • the app sent nothing
  • the test polled the wrong inbox
  • the right inbox was queried too early
  • an older message matched before the fresh one arrived
  • the assertion looked only at subject text and ignored timing

Those failures can all collapse into the same timeout, which is why the logs feel unhelpfull. If someone drops a note like temp mailid into a bug ticket, you still need stronger evidence than "the poller waited 20 seconds."

I like pairing this with run IDs for flaky notification jobs, because the run ID gives your inbox activity a clean owner. In CI, ownership is half the battle.

The inbox contract I ask every test to keep

The contract is tiny on purpose. If it grows into a giant debug artifact, nobody reads it. For most QA flows I only keep:

  • scenario name
  • CI job or worker id
  • normalized recipient
  • trigger timestamp
  • expected subject fragment
  • optional expected sender
  • poll deadline
  • inspected message ids
  • final outcome

That list tells me what the test promised to look for and how it made the decision. It also helps when people mix terms like tepm mail com into issue notes and you need to prove which environment or inbox the run actualy used.

One more detail matters: write the contract result on success too. Teams often save artifacts only when the test fails, but the successful run from yesterday is often the best baseline for comparing today's flaky one.

A Cypress task that captures the contract

I prefer doing the inbox poll in a Cypress task so the browser spec stays readable. The task can return a structured receipt instead of a bare boolean.

// cypress.config.js
on("task", {
  async waitForInboxMessage({ inboxClient, claim }) {
    const receipt = {
      scenario: claim.scenario,
      workerId: process.env.CI_NODE_INDEX || "local",
      recipient: claim.recipient.toLowerCase(),
      subjectIncludes: claim.subjectIncludes,
      triggeredAt: Date.now(),
      deadlineAt: Date.now() + 20000,
      inspectedIds: [],
      outcome: "timeout",
      matchedId: null
    };

    while (Date.now() < receipt.deadlineAt) {
      const messages = await inboxClient.listMessages(receipt.recipient);

      for (const message of messages) {
        receipt.inspectedIds.push(message.id);

        if (
          message.subject.includes(receipt.subjectIncludes) &&
          Date.parse(message.receivedAt) >= receipt.triggeredAt
        ) {
          receipt.matchedId = message.id;
          receipt.outcome = "matched";
          return receipt;
        }
      }

      await new Promise((resolve) => setTimeout(resolve, 1000));
    }

    return receipt;
  }
});
Enter fullscreen mode Exit fullscreen mode

Then in the spec, save the receipt as an artifact and assert on outcome. That separation keeps the test readable, and it gives QA a boring, repeatable object to inspect later. If you already keep small run manifests for scheduled jobs, the receipt can sit right beside them.

How the contract changes failure triage

The nice part is not that logs get bigger. The nice part is that triage gets shorter.

If inspectedIds is empty, I start at the trigger path or inbox routing. If there are several inspected messages but all predate triggeredAt, I look for stale cleanup or cross-test pollution. If the message arrived on time but never matched, I inspect the template or sender filter.

That is a much better convo between QA and engineering than "email test failed again." It also stops people from guessing whether Cypress itself is the problem. Often it isnt. The test just never recorded enough evidence to explain what happened.

A pre-merge checklist for QA teams

Before merging any new email-dependent Cypress check, I like to verify these:

  • the test records triggeredAt right next to the user action
  • inbox ownership is unique per scenario or per run
  • the receipt stores inspected message ids
  • older messages cannot satisfy the new assertion
  • success and failure both save the same artifact shape
  • CI output points to the stored receipt file

None of this is fancy, but it makes QA calmer and handoffs cleaner. That is worth a lot when a suite starts failing at 2 AM and someone who did not write the test has to debug it.

Q&A

Do I need this for every email test?

If the test can fail because of timing, inbox reuse, or delayed delivery, yes, I think so. The extra structure is small and the debugging value is very real.

Is this only useful with Cypress?

No. The idea works anywhere. Cypress just benefits quickly because many teams already push inbox polling into tasks or plugins.

Does this replace inbox isolation?

Nope. Isolation still matters. The contract just makes failures explainable when isolation is imperfect or CI is under load.

Top comments (0)