If you have ever automated a signup flow, you already know the awkward part is not filling the form. The hard part is the email verification step. The application sends a message, the test has to wait for it, extract a link or code, then continue in a separate browser state. That is where many otherwise solid end-to-end suites become flaky, slow, or too brittle to trust.

This is one of the most common places where I see teams under-test real user journeys. They mock the email service in unit tests, maybe assert that an API call was made, then stop there. That is useful, but it does not prove that the user can actually receive the message, open it, and complete verification. If your product depends on signup conversion, password resets, magic links, 2FA, or order notifications, you need at least some coverage for the full email round trip.

In this tutorial, I will show how I approach a test email verification with Playwright workflow using either an email API or a test inbox. I will also call out the tradeoffs, because email-based E2E testing is one of those areas where the simplest solution often depends on your product and team structure. If you want a simpler route, I will also show where Endtest’s email and SMS testing can remove a lot of setup overhead for teams that do not want to own the whole plumbing.

What you are actually testing

A good email verification test is not just “did we send an email?” It usually needs to validate several things:

  • The signup form submits successfully
  • The backend creates a pending user or account state
  • The verification email is delivered to a real or test-controlled mailbox
  • The email content is correct, including subject, sender, and link or code
  • The verification link or OTP takes the user to the expected page
  • The account becomes verified after completion
  • The app handles retries, expired links, and duplicate requests gracefully

The most valuable email verification test is the one that proves the real workflow end to end, not just the message template.

That sounds straightforward, but the implementation details matter. Email is an asynchronous dependency, and asynchronous systems create timing issues. If you do not plan for that, your tests will become hard to diagnose.

The main ways to test email verification flows

There are three practical approaches I see most often.

1. Use a test inbox service or disposable mailbox

This is the most common option for Playwright teams. Your application sends email to a mailbox you control in tests, then your test reads the inbox through an API or IMAP, extracts the verification link, and continues.

Common choices include:

  • Temporary inbox providers
  • Dedicated test inboxes with API access
  • Email capture tools that expose messages in a sandbox
  • IMAP-accessible mailboxes in a test domain

This is usually the best balance of realism and maintainability when you are already using Playwright and want code-level control.

2. Use your email provider’s sandbox or API hooks

Some teams use a transactional email provider, then query events or messages through its API. This can be stable if the provider exposes message metadata quickly and predictably. It is also useful for verifying that your backend really called the provider with the correct payload.

The downside is that not every provider gives you message bodies or links in a friendly way. Some only give status metadata, which is enough for delivery assertions but not enough to complete the end-to-end user journey.

3. Use a platform that natively handles email flows

If your team wants to avoid building and maintaining the inbox plumbing, a platform like Endtest can be a simpler alternative. Endtest is a managed, low-code, agentic AI test automation platform, and its email workflow support is designed for tests that need to receive, parse, and act on messages without stitching together custom infrastructure.

For teams with mixed technical skill, that can be a big deal. You still get real workflow coverage, but you do not have to own mailbox provisioning, browser setup, inbox polling, or a pile of brittle helper code.

A practical Playwright architecture for email verification tests

My default recommendation is to keep the browser flow and the email retrieval flow separate, but orchestrated by the same test.

A typical flow looks like this:

  1. Generate a unique email address for the test run
  2. Create a signup account in the browser
  3. Wait for the verification message to arrive
  4. Read the email through an API or IMAP client
  5. Extract the verification URL or code
  6. Open the link or submit the code in the browser
  7. Assert the account is verified and the user is signed in or redirected correctly

The test should be able to fail clearly at each step. If the email never arrives, you want to know that. If the link is malformed, you want to know that too. A vague “timed out waiting” message is not enough.

Example: Playwright signup flow with email polling

Below is a simplified TypeScript example. It assumes you have a helper that can read a verification email from your test inbox API.

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

async function waitForVerificationLink(email: string): Promise { const deadline = Date.now() + 60_000;

while (Date.now() < deadline) { const response = await fetch(https://mail-api.example.test/messages?to=${encodeURIComponent(email)}); const messages = await response.json(); const verification = messages.find((m: any) => m.subject.includes(‘Verify your account’));

if (verification?.body) {
  const match = verification.body.match(/https:\/\/app\.example\.com\/verify\/[A-Za-z0-9-_]+/);
  if (match) return match[0];
}

await new Promise((r) => setTimeout(r, 3000));   }

throw new Error(Verification email not received for ${email}); }

test('user can sign up and verify email', async ({ page }) => {
  const email = `pw-${Date.now()}@test.example.com`;

await page.goto(‘https://app.example.com/signup’); await page.getByLabel(‘Email’).fill(email); await page.getByLabel(‘Password’).fill(‘StrongPassw0rd!’); await page.getByRole(‘button’, { name: ‘Create account’ }).click();

await expect(page.getByText(‘Check your email’)).toBeVisible();

const verifyUrl = await waitForVerificationLink(email); await page.goto(verifyUrl);

await expect(page.getByText(‘Email verified’)).toBeVisible(); });

This version is intentionally plain. In a real suite, I would usually separate concerns a bit more:

  • A helper for creating unique test data
  • A helper for inbox polling and parsing
  • Assertions for message subject and sender
  • Clear logging when the email is found but the link is missing
  • Optional cleanup for test accounts and inbox records

How to make inbox polling less flaky

Email delivery is not instant. Your test should expect that and wait intelligently.

Prefer polling over fixed sleeps

A fixed waitForTimeout(30000) is a maintenance trap. Sometimes the email arrives in two seconds, sometimes in 20. Polling with a deadline gives you faster feedback and more stable failure behavior.

Use a loop like this instead of one long sleep:

  • Poll every 2 to 5 seconds
  • Stop after a maximum wait time, often 60 to 120 seconds
  • Fail with a useful error if no relevant message arrives

Filter messages narrowly

Do not scan the entire inbox if you can avoid it. Filter by:

  • Recipient address
  • Subject line
  • Sender
  • Time window
  • Message type or tags, if your inbox provider supports them

That reduces false positives, especially if your test inbox receives multiple messages during a run.

Parse the right content

Email templates often contain multiple links, tracking parameters, or HTML and plain-text variants. Your parser should know which element matters. I prefer to parse the HTML when available, then validate the URL structure before clicking it.

If the app sends a code instead of a link, treat the code as a first-class test artifact. Extract it, log it in a masked way if needed, then submit it through the UI.

Some apps use numeric OTPs or short codes. The structure of the test is the same, but the extraction logic changes.

function extractCode(body: string): string {
  const match = body.match(/\b(\d{6})\b/);
  if (!match) throw new Error('Verification code not found');
  return match[1];
}

After that, the browser step is straightforward:

typescript

await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByText('Account verified')).toBeVisible();

The thing to watch here is code expiration. If your code expires in 5 minutes, your test is fine. If it expires in 30 seconds and the inbox provider is slow, you may need to isolate that test or adjust the timeout policy.

What to assert in the email itself

A lot of teams stop at “I found an email and clicked the link.” That misses bugs in the notification content and routing.

At minimum, assert:

  • Subject line matches the expected template
  • Sender address is correct
  • Recipient matches the test account
  • The body contains the expected account or product context
  • The verification link points to the right environment

If your app has multiple environments, this matters a lot. I have seen tests accidentally click production links from staging email templates because the link host was not validated.

Always check the destination URL, not just the presence of a link. Email tests can accidentally validate the wrong environment if you do not constrain the host.

Local development versus CI

Email verification tests are usually easier locally than in CI, but the CI version is the one that matters.

Local runs

For local runs, developers often use:

  • A sandbox inbox service
  • A fake test mailbox with a visible dashboard
  • A local SMTP catcher for non-E2E checks

Local convenience is useful, but do not confuse a mailbox catcher with a real end-to-end test. If you are not reading the email through the same path your production system uses, you are only partially testing the flow.

CI runs

In CI, the test should be deterministic and isolated. A few practical recommendations:

  • Use a unique email address per run
  • Keep the inbox credentials in secrets, not in the repository
  • Run these tests in a separate test stage, not on every small unit test change
  • Make sure the application environment sends to the correct test domain
  • Add cleanup for abandoned test accounts if the platform supports it

Here is a minimal GitHub Actions example for Playwright E2E tests with an email verification suite:

name: e2e

on: push: pull_request:

jobs: playwright: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npx playwright test tests/email-verification.spec.ts env: TEST_INBOX_API_KEY: $ BASE_URL: https://staging.example.com

Common failure modes I see in email verification tests

1. The email arrives, but the test reads the wrong message

This happens when you reuse inboxes or fail to filter properly. Unique test data helps a lot, and so does a clear tagging strategy if your inbox API supports it.

If verification depends on same-tab or same-session state, opening the link in a new page can change behavior. Make sure you know whether the verification endpoint should work with or without the original browser context.

3. The test passes, but the user is not truly verified in the backend

Do not stop at the UI. After clicking the verification link, assert the persisted state if you can, either through the UI or a backend API.

4. The test is slow because the inbox is slow

This is often a sign that the flow is too coupled to an external provider. If you cannot speed up the mailbox path, consider reducing the number of tests that need to go through it. One or two canonical flows is usually enough.

5. The test breaks when the email template changes slightly

If your parser depends on exact markup, it will be fragile. Prefer robust link extraction and stable semantic assertions over snapshotting the entire HTML email body.

When to use mocking, and when not to

I am not ضد mocking email, but it should be used in the right layer.

Use mocks for:

  • Unit tests of email generation logic
  • API tests that verify message payloads are assembled correctly
  • Fast feedback on edge cases like missing recipient or malformed link

Use real email flows for:

  • Signup verification
  • Password reset
  • Magic-link login
  • 2FA and OTP flows
  • Customer-facing notification paths that matter to conversion or access

If the issue would block a real user, I want at least one real E2E test around it.

Where Playwright fits best, and where it starts to hurt

Playwright is excellent when your team is comfortable writing code and wants complete control over browser behavior, fixtures, and assertions. It is especially good if your email workflow needs custom parsing, complex state setup, or deep integration with backend APIs.

The downside is that you own all the glue:

  • Browser automation code
  • Inbox integration code
  • Retries and timeout policy
  • CI wiring
  • Maintenance when templates or selectors change

That is manageable for one team with strong automation skills. It gets heavier when the organization wants product managers, manual testers, or less code-heavy contributors to help build or maintain coverage.

That is where a tool like Endtest becomes interesting. It is a managed, no-code platform with agentic AI workflows, so teams can cover signup and email-based journeys without taking on framework setup, driver management, or custom inbox infrastructure. For organizations that need the same coverage but less code ownership, that tradeoff can be more practical than adding another layer of helper code around Playwright.

A decision framework I use in practice

Choose Playwright if:

  • Your team already writes browser automation in TypeScript or Python
  • You need highly custom parsing, assertions, or API orchestration
  • You want full control over your test stack
  • You are comfortable maintaining inbox helpers and CI setup

Choose a managed no-code approach if:

  • You want broader team participation in test creation
  • You care more about workflow coverage than framework ownership
  • You are tired of maintaining inbox polling code and browser plumbing
  • Your team wants end-to-end email workflows inside one platform

The right choice is not about ideology. It is about where you want to spend engineering time. If email verification is a core business flow and your team is already stretched thin, a simpler platform can save a lot of operational overhead.

A few implementation details that save time later

  • Generate unique email addresses per test run, but keep them easy to correlate in logs
  • Store the verification email metadata for debugging, subject, timestamp, recipient
  • Validate that links point to the expected environment before opening them
  • Prefer one happy-path test and a few edge-case tests over many near-duplicates
  • Add explicit timeouts for inbox polling, do not let tests hang indefinitely
  • Clean up test users if your application does not automatically expire them

Final thoughts

Testing email verification flows is one of those tasks that looks small until it breaks real users. Once you move beyond simple UI assertions, you have to coordinate browser automation, asynchronous message delivery, parsing, and backend state checks. Playwright handles the browser part very well, but the email part is on you unless you add a dedicated inbox strategy.

If your team likes code and wants full flexibility, Playwright is a strong fit for E2E email testing. If your real problem is not browser automation but the infrastructure and maintenance around it, a platform that natively supports email workflows can be a better fit. That is why I would seriously evaluate Endtest alongside Playwright for signup and verification coverage, especially when you want a simpler path to maintainable, team-friendly tests.

Either way, do not stop at “the email was sent.” The real goal is to prove that a user can sign up, receive the message, act on it, and move forward without friction.