DEV Community

Silviu Technology
Silviu Technology

Posted on

Playwright OTP Tests Need Clock Boundaries

Playwright OTP Tests Need Clock Boundaries

OTP resend tests look simple on paper: request a code, wait for the email, request a second code, confirm the old one is invalid. In practice, these flows fail because the test has fuzzy timing rules. The browser is fine, the worker is mostly fine, but the suite does not define when a code becomes stale, when a resend is allowed, or which mailbox event belongs to which request.

That is why I treat OTP automation as a clock problem first and a Playwright problem second. If the timing contract is vague, the test will pass for the wrong reason one run and fail for a weird reason the next. You start seeing notes like temp mail so, tempmail.so, temp mailid, or even temp gamil com in bug reports because people are debugging around the mailbox instead of the actual boundary.

Why OTP resend tests fail more than they should

Most flaky OTP tests I review have one of these issues:

  1. The test assumes the newest email is the valid one.
  2. The product allows resend after N seconds, but the test waits "around that long".
  3. The assertion checks the final success page, but not the invalidation of the first code.
  4. Failure logs do not capture which code arrived first, which one was used, and when.

Google has written that flaky tests create a serious productivity tax across engineering teams, and that matches what QA folks feel every week when reruns become normal instead of exceptional. See the Google Testing Blog on flaky tests. OTP flows are a classic spot for that tax because email delivery, background jobs, and client waits all stack up in one path.

If your team already tightened mailbox matching, posts about stable inbox contracts are a good base. For OTP flows, I add one more rule: every assertion must name the time boundary it depends on.

Set clock boundaries before you blame Playwright

I like to write the resend contract in plain language before I touch the test:

  • A code is valid for 5 minutes.
  • Resend is blocked for the first 30 seconds.
  • A resend invalidates the previous code within one processing cycle.
  • The inbox waiter must match by recipient plus scenario id, not by "latest message".

That contract gives you something concrete to automate. Without it, people write sleeps and call it realism, which is not realism, its drift with nicer branding.

There is also a content side to this. If you are testing social onboarding, a disposable inbox can be helpful for high-churn scenarios like temp mail for facebook, but the inbox choice should stay secondary to the timing rules. The provider can help isolate messages; it cannot fix a vague resend contract.

I also split failures into two buckets:

  • Delivery failures: the expected message never appears.
  • Boundary failures: the second message arrives, but the first code still works too long or the resend gate opens too early.

That split matters a lot, because the fix paths are differnt. One belongs to jobs, queues, or inbox polling. The other belongs to auth logic and state transitions.

A fixture pattern for mailbox plus timer assertions

For Playwright, I want one fixture that creates a mailbox and one helper that records timing evidence. The helper does not just wait for a message. It remembers when the request was made, when each message arrived, and which code was attempted.

import { test, expect } from "@playwright/test";

test("resend invalidates the first OTP", async ({ page }) => {
  const scenarioId = `otp-${Date.now()}`;
  const inboxAddress = await inbox.createAddress({ scenarioId });

  await page.goto("/login");
  await page.getByLabel("Email").fill(inboxAddress.address);
  await page.getByRole("button", { name: "Send code" }).click();

  const firstMessage = await inbox.waitForMessage({
    recipient: inboxAddress.address,
    scenarioId,
    subjectIncludes: "Your sign-in code",
    timeoutMs: 20000
  });

  await page.waitForTimeout(31000);
  await page.getByRole("button", { name: "Resend code" }).click();

  const secondMessage = await inbox.waitForNewerMessage({
    recipient: inboxAddress.address,
    scenarioId,
    afterMessageId: firstMessage.id,
    subjectIncludes: "Your sign-in code",
    timeoutMs: 20000
  });

  await page.getByLabel("Code").fill(firstMessage.otp);
  await page.getByRole("button", { name: "Continue" }).click();
  await expect(page.getByText("Code expired")).toBeVisible();

  await page.getByLabel("Code").fill(secondMessage.otp);
  await page.getByRole("button", { name: "Continue" }).click();
  await expect(page).toHaveURL(/dashboard/);
});
Enter fullscreen mode Exit fullscreen mode

The big thing here is waitForNewerMessage. I do not want "some OTP email". I want the OTP email that happened after the resend event. That one distinction catches a lot of bugs and makes triage less miserable.

I also keep a tiny evidence object per run:

  • initial request timestamp
  • resend click timestamp
  • first message id and arrival time
  • second message id and arrival time
  • result of trying the first code after resend

When a test flakes, this evidence lets you answer whether the suite raced the system or the system broke its own promise. It sounds small, but it saves hours over a month, easy.

If your suite still mixes up messages from nearby scenarios, review patterns for catching the wrong confirmation email. OTP failures often look like timing bugs at first, then turn out to be mailbox selection bugs.

Checklist for reviewing resend flows

Before I trust an OTP resend test, I check these:

  1. Does the product spec define resend delay and code expiry in exact numbers?
  2. Does the test assert that the old code fails after resend?
  3. Does the inbox helper wait for a newer message, not just any message?
  4. Are timestamps and message ids saved on failure?
  5. Can the same flow be reproduced locally without changing timeout constants?

If the answer to item 2 or 3 is no, the suite may look green while still missing the real regression. That happens more than teams expect, actualy.

Quick Q&A

Should I mock the mailbox instead?

For lower-level auth tests, yes, sometimes. For end-to-end release gates, I still want one real inbox path so I can prove delivery, parsing, and invalidation all line up.

Are fixed waits always bad?

Not always. A short, intentional wait that matches the resend lock can be fine. A vague sleep added after a flaky run is usualy bad because it hides the missing contract.

What is the most important failure artifact?

The timeline. If you know when request one fired, when resend fired, and when each message landed, most OTP failures stop being mysterious.

Reliable OTP tests come from explicit boundaries, not bigger timeouts. Once the suite names the resend window and proves the first code dies when the second one appears, Playwright stops looking random and starts looking honest.

Top comments (0)