TestWatchStart free

Test email verification in Playwright

Signup, password reset and magic-link login all wait on an email. A test that types test@example.com can’t read it. TestWatch gives every run real inbox addresses on every plan, so the test can finish the flow.

by Vasyl Harastei

The test

import { test, expect, inbox, waitForEmail } from '../fixtures';

test('Sign up and verify the email address', async ({ page }) => {
  const box = inbox('signup');

  await test.step('Create an account', async () => {
    await page.getByRole('link', { name: 'Sign up' }).click();
    await page.getByRole('textbox', { name: 'Email' }).fill(box.address);
    await page.getByRole('textbox', { name: 'Password' }).fill('TestPassword123!');
    await page.getByRole('button', { name: 'Create account' }).click();
    await expect(page.getByText('Check your inbox')).toBeVisible();
  });

  await test.step('Open the verification link', async () => {
    const mail = await waitForEmail({ subject: /verify/i });
    await page.goto(mail.link!);
    await expect(page.getByRole('heading', { name: 'Email verified' })).toBeVisible();
  });
});

A test like this one. When TestWatch writes a signup test, it uses inbox() for the email field. For an emailed one-time code, read mail.code instead:

await test.step('Enter the emailed code', async () => {
  const mail = await waitForEmail({ subject: /code/i });
  await page.getByRole('textbox', { name: 'Verification code' }).fill(mail.code!);
  await page.getByRole('button', { name: 'Continue' }).click();
});

How the inbox works

Each call to inbox() makes a new receive-only address on our inbox domain, so a re-run doesn’t collide with a user that already exists. The address starts with the run’s tw- tag, so cleanup by prefix covers verified signups too. Mail is kept for 24 hours.

waitForEmail() reads the latest inbox address of the test. It matches subject or from by text or a regular expression. If several emails match, the newest wins, so a “resend” replaces the original. Each email is returned once, so a second call gets the next one. The wait is a fixed 45 seconds. The result has the email’s link (an action link on your app’s own host is preferred) and its code, if it found one.

Not every email field needs an inbox. testEmail() gives a unique address that never receives mail, on example.com or a test domain you set. Use it when nobody reads the email.

When it runs

Inboxes are on every plan, including Free. While TestWatch writes the test, its agent signs up with a real inbox address and reads the email itself, so the test you get has already been through the flow. After that, every scheduled, manual or CI run makes new addresses. A passing run makes no AI calls, so reading an email adds no AI cost. For a password reset or a login code, a test account can have its own fixed inbox address. The test then calls waitForEmail() without inbox() and reads the newest mail sent since the test started.

What you see when it fails

If no matching email arrives within 45 seconds, the test fails with TESTWATCH_EMAIL_NOT_RECEIVED. TestWatch then looks at what did arrive. Mail with another subject usually means the email was renamed, so it proposes a new subject match for you to approve. No mail at all is flagged as your app not sending the email. The result’s Technical tab lists the emails received, with link tokens masked and the code shown only by its shape, such as “6-digit code”. If our inbox service can’t be read, the run errors instead of blaming your app.

What breaks it

  • Your staging environment has to send real email, and its signup has to accept the inbox domain. Some apps block unknown or disposable domains.
  • Codes sent by SMS aren’t supported.
  • Signing in to your app through Google, GitHub or SSO isn’t supported.
  • An exported suite needs your own email provider for these tests (see export).

Start free See how it works

Free: 5 tests, 500 runs a month, 2 apps and one daily schedule. Unlimited teammates, no card.