DEV Community

Silviu Technology
Silviu Technology

Posted on

Playwright Email Tests Need a State Ledger

Email verification tests often fail in a confusing way: the browser says that the confirmation page is ready, but the message belongs to a previous run, the link is expired, or the inbox has not received anything yet. The final assertion is where the test stops, not where the failure started.

This is the workflow I use when a Playwright email test needs better evidence. I give every run a small state ledger. The ledger records what the test asked for, what message it observed, and which verification state was reached. It turns a vague “email test failed” into a short causal story that QA can act on.

Why a passing browser assertion can hide an email failure

An email verification flow crosses several systems:

  1. The browser submits an address.
  2. The application creates a verification request.
  3. A mail service accepts and delivers the message.
  4. The test reads the message and opens its link.
  5. The application changes the account state.

If the test only asserts the last step, all five stages look like one operation. A stale message can satisfy a loose subject check. A shared inbox can make the test pass with an address from another run. A retry can then hide the original problem.

A burner email may help isolate test data, but isolation alone does not explain timing or message identity. The test still needs to say which request it is waiting for.

Model the verification flow as a state ledger

Keep the ledger small and append-only. For each run, record an event such as:

type EmailEvent = {
  runId: string;
  state: "requested" | "delivered" | "opened" | "verified" | "expired";
  messageId?: string;
  observedAt: string;
  detail?: string;
};
Enter fullscreen mode Exit fullscreen mode

The important field is runId. Put it in the test address, a request header, or metadata that your mail test fixture can query. The exact transport matters less than being able to connect a message to one browser run.

Do not overwrite the previous event when a poll repeats. That small choice preserve the sequence and makes a delayed delivery visible. It also keeps a test retry from erasing the evidence produced by the first attempt.

For sensitive environments, pair the ledger with a redaction contract for verification logs. Logs should identify the message without copying the full token or mailbox contents.

Capture the ledger in a Playwright test

The browser test can write events beside its trace. Here is a deliberately simple pattern:

const events: EmailEvent[] = [];
const record = (state: EmailEvent["state"], detail?: string) => {
  events.push({ runId, state, observedAt: new Date().toISOString(), detail });
};

record("requested");
await page.getByRole("button", { name: "Send verification email" }).click();

const message = await inbox.waitForMessage({
  to: testAddress,
  subject: /Verify your account/,
  timeoutMs: 30_000,
});
record("delivered", message.id);

await page.goto(message.verificationUrl);
record("opened", message.id);
await expect(page.getByText("Email verified")).toBeVisible();
record("verified", message.id);
Enter fullscreen mode Exit fullscreen mode

In a real fixture, waitForMessage should reject messages whose run marker does not match. It should also report the last polling observation in its error, not only “timeout”. The test pass is more useful when the failure says “requested, no matching delivery after 30 seconds” than when it says “expected text to be visible”.

Diagnose the first bad state

When a run fails, inspect the first transition that did not happen. If requested is missing, the browser action or fixture setup failed. If delivered is missing, check the mail service, address routing, and delivery timeout. If opened is missing, inspect URL extraction and token expiry. If verified is missing, the application probably rejected the token or the page assertion is looking at the wrong state.

This order stops a common debugging mistake: changing Playwright waits when the message was never delivered. More waiting can make the suite slower while keeping the root cause untouched.

For CI pipelines, store the ledger with the trace and link it to a promotion receipt for CI email tests. A failed run then has both the browser evidence and the environment transition that produced it.

A small QA checklist for reliable email tests

  • Give each run a unique marker and test address.
  • Match the recipient, run marker, and message purpose.
  • Record every state transition; do not only record the final result.
  • Make polling errors include the last observed state.
  • Expire or clean test data after the run, even on failure.
  • Redact tokens, message bodies, and personal addresses from artifacts.
  • Keep one focused test for delivery and another for token acceptance.

If your team calls a fixture “temp org mail” in a note or dashboard, keep that label separate from the real run identifier. Informal names are handy, but they are not a reliable correlation key.

The goal is not to add a large observability platform to every end-to-end test. It is to preserve enough evidence to answer one question: what was the first email state that became false? Once that answer is recorded, Playwright failures become easier to reproduce, triage, and fix.

Top comments (0)