DEV Community

sms-florin
sms-florin

Posted on

Testing Email Verification in Playwright with a Free Disposable Inbox (No Signup, No API Key)

Most Playwright suites stop right before the hard part of a signup flow: the "confirm your email" link, the emailed 6-digit code, the password-reset mail. The usual reasons are that you need a real inbox your test can read, and every option seems to want an account, an API key, or a paid plan.

This post shows how to test those flows end to end with a disposable inbox that needs no signup and no API key. Full disclosure: I built the inbox service (receivemail.dev) and the small Playwright fixture used below, because I wanted exactly this for my own projects.

The idea: one fresh inbox per test

The pattern that keeps email tests reliable:

  1. Create a brand-new inbox for each test (no shared state, no stale mail from a previous run).
  2. Sign up in your app with that address.
  3. Poll the inbox until the expected email arrives, with a timeout.
  4. Pull the link or code out of the body and finish the flow.

Install

npm i -D playwright-receivemail
Enter fullscreen mode Exit fullscreen mode

@playwright/test is a peer dependency, so nothing else to add.

Test a "verify your email" link

Import test and expect from the package instead of @playwright/test. You get a mailbox fixture: a fresh @receivemail.dev address for every test.

import { test, expect } from "playwright-receivemail";

test("a new user can verify their email", async ({ page, mailbox }) => {
  await page.goto("/signup");
  await page.getByLabel("Email").fill(mailbox.address);
  await page.getByLabel("Password").fill("test-Pa55w0rd");
  await page.getByRole("button", { name: "Create account" }).click();

  // Wait for the email, then pull the verification link out of it
  const link = await mailbox.waitForMatch(/https:\/\/[^\s"]+\/verify[^\s"]*/, {
    subjectContains: "verify",
    timeout: 30_000,
  });

  await page.goto(link);
  await expect(page.getByText("Email verified")).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Adjust the regex and the final assertion to your app's link format and confirmation text.

Test an emailed OTP code

const code = await mailbox.waitForMatch(/\b\d{6}\b/);
await page.getByLabel("Verification code").fill(code);
Enter fullscreen mode Exit fullscreen mode

Assert on the whole message

const msg = await mailbox.waitForMessage({ fromContains: "noreply@yourapp" });
expect(msg.subject).toContain("Welcome");
Enter fullscreen mode Exit fullscreen mode

waitForMessage polls every 2 seconds and throws after 30 seconds by default (both configurable). By default it also ignores anything that was already in the mailbox before the test started, so a re-used address can't make a test pass on yesterday's email.

Already extending test?

Compose it instead of replacing your own fixtures:

import { test as base } from "@playwright/test";
import { withReceivemail } from "playwright-receivemail";

export const test = withReceivemail(base, { ttlMinutes: 30 });
Enter fullscreen mode Exit fullscreen mode

Mailboxes live 15 minutes by default (1 to 60 configurable), which is plenty for a CI run.

Without the fixture (any test runner)

The fixture is a thin wrapper over two HTTP calls, so you can use the same inbox from Cypress, Jest, a shell script, or anything else:

# 1. create an inbox: returns { address, expiresAt, secret }
curl -s -X POST https://receivemail.dev/mailboxes

# 2. read it (the secret from step 1 is the bearer token)
curl -s https://receivemail.dev/mailboxes/<address>/messages \
  -H "authorization: Bearer <secret>"
Enter fullscreen mode Exit fullscreen mode

There's also a Cypress plugin (cypress-receivemail) with cy.createMailbox() and cy.waitForEmail().

When not to use this

This is built for test environments: short-lived inboxes for test accounts. Each inbox can only be read with the secret returned when it was created, but expired inboxes and their mail are wiped in a daily cleanup, so don't point real users or anything you need to keep at it. If you need long retention or an SLA, use a paid service.

Links

If you try it, I'd like to hear what broke or what was missing.

Top comments (0)