DEV Community

Lukas Lewandowski
Lukas Lewandowski

Posted on

Testing Email Verification with Playwright and DevMail

A signup test can fill every field, click Create account, and still leave an important part of the journey unchecked: the verification email.

Did it arrive? Was it sent to the right address? Does its link actually verify the account?

I built DevMail for development and QA email workflows, and I use its API in a real Playwright test suite. In this post, I'll show the path from submitting a signup form to fetching the email and following its verification link in the browser.

The example is adapted from that integration. I've simplified the application UI so you can reuse the email testing pattern in your own project.

A persistent address for your tests

Your application sends its usual verification email to the test address. Playwright retrieves the received message through DevMail's API.

DevMail gives you a persistent testing email address, a web UI for inspecting messages, and an API for automated checks. Plus-address aliases let multiple test scenarios share the inbox while using different recipient addresses.

Your inbox address stays the same while old messages are removed automatically. The Free plan keeps messages for seven days, so you don't have to manage cleanup yourself.

Before running the example

You can start using DevMail for free. Create an account, copy your generated inbox address, and create an API key in your account settings.

Provide these environment variables to your existing Playwright project:

export DEVMAIL_API_KEY='your_api_key_here'
export DEVMAIL_EMAIL_INBOX='your-inbox@example.com'
export APP_BASE_URL='https://app.example.com'
Enter fullscreen mode Exit fullscreen mode

The addresses above are fictional placeholders. Use your actual generated inbox and your application's test environment. Keep the API key in your local environment or CI secret store.

Set APP_BASE_URL to your application's origin (scheme, hostname, and optional port), without a path. The example opens /signup at that origin; adapt the route if your app lives under a path such as /app.

Your test environment must send email to external recipients. Check your email provider's sandbox restrictions and make sure a local SMTP catcher isn't intercepting the messages.

This example also requires your signup form to accept and preserve +tag addresses. If validation rejects them, this pattern won't work as-is.

This example assumes /signup has Email and Password fields, a Create account button, and a Check your email message. Successful verification displays an Email verified heading. Adapt those selectors and the sample password to your application's requirements.

In this example, the app sends an email with the subject Email Address Verification. This excerpt shows the expected plain-text layout, adapted from the integration's email template with fictional names and URL:

Dear Alex,

An account has been created for you.
Please confirm your email address to start enjoying Example App.

Confirm Email Address (***REDACTED***

Your Example App Team
Enter fullscreen mode Exit fullscreen mode

The complete Playwright test

Save this as tests/email-verification.spec.ts:

import { randomUUID } from 'node:crypto';
import { test, expect, request as playwrightRequest } from '@playwright/test';

interface MessageSummary {
  id: number;
  to: string;
  subject: string | null;
}

test('verifies a new account through its email', async ({ page }) => {
  test.setTimeout(90_000);

  const apiKey = process.env.DEVMAIL_API_KEY;
  const inbox = process.env.DEVMAIL_EMAIL_INBOX;
  const appURL = process.env.APP_BASE_URL;
  if (!apiKey || !inbox || !appURL) {
    throw new Error('Set DEVMAIL_API_KEY, DEVMAIL_EMAIL_INBOX, and APP_BASE_URL');
  }

  const parts = inbox.split('@');
  const [localPart, domain] = parts;
  if (parts.length !== 2 || !localPart || !domain) {
    throw new Error('DEVMAIL_EMAIL_INBOX must be your generated base inbox address');
  }
  const runId = randomUUID().replaceAll('-', '').slice(0, 16);
  const email = `${localPart}+signup-${runId}@${domain}`;
  const subject = 'Email Address Verification';
  const devmail = await playwrightRequest.newContext({
    baseURL: 'https://api.devmail.space',
    extraHTTPHeaders: { Authorization: `Bearer ${apiKey}` },
    timeout: 10_000,
  });

  try {
    await page.goto(new URL('/signup', appURL).href);
    await page.getByLabel('Email', { exact: true }).fill(email);
    await page.getByLabel('Password', { exact: true }).fill(`E2e!${randomUUID().slice(0, 12)}`);
    await page.getByRole('button', { name: 'Create account', exact: true }).click();
    await expect(page.getByText('Check your email', { exact: true })).toBeVisible();

    let messageId: number | undefined;
    await expect
      .poll(
        async () => {
          // Fail fast on request errors; expect.poll does not retry callback errors.
          const response = await devmail.get('/api/v1/messages', {
            params: { to: email, subject, limit: 20 },
          });
          await expect(response).toBeOK();
          const { messages }: { messages: MessageSummary[] } = await response.json();
          messageId = messages.find(
            (message) =>
              message.to.toLowerCase() === email.toLowerCase() && message.subject === subject
          )?.id;
          return messageId;
        },
        {
          message: 'Wait for the verification email for this signup',
          timeout: 60_000,
          intervals: [2_000, 5_000],
        }
      )
      .toBeDefined();

    // expect.poll does not narrow messageId for TypeScript.
    if (messageId === undefined) throw new Error('Verification email was not found');
    const response = await devmail.get(`/api/v1/message/${messageId}`);
    await expect(response).toBeOK();
    const { body }: { body: { text: string } } = await response.json();
    const match = body.text.match(/Confirm Email Address\s+\((https?:\/\/\S+)\)/);
    if (!match) throw new Error('Missing Confirm Email Address link in the plain-text body');

    const verificationURL = new URL(match[1]);
    expect(verificationURL.origin).toBe(new URL(appURL).origin);
    await page.goto(verificationURL.href);
    await expect(page.getByRole('heading', { name: 'Email verified', exact: true })).toBeVisible();
  } finally {
    await devmail.dispose();
  }
});
Enter fullscreen mode Exit fullscreen mode

Run it with:

npx playwright test tests/email-verification.spec.ts
Enter fullscreen mode Exit fullscreen mode

Finding the email that belongs to this test

Each signup and retry gets a fresh random alias such as your-inbox+signup-<run-id>@example.com. Parallel tests share the inbox but search for their own recipient. Selecting the newest message could pick another run's email.

DevMail's to and subject filters use case-insensitive substring matching, so the test also checks the full recipient and exact subject. The recipient comparison ignores case to handle apps that lowercase email addresses. The unique alias identifies this signup's email, so no timestamp filter is needed. Playwright's params encodes the alias's + as %2B, preserving it in the query string.

Waiting for delivery and reading the body

Email delivery is asynchronous. Playwright's expect.poll checks immediately, then repeats the query while successful responses contain no matching email, up to 60 seconds. With intervals: [2_000, 5_000], it waits two seconds after the first unsuccessful check, then five seconds between later checks.

This example deliberately fails fast on HTTP errors, including 401, 403, 429, and 5xx, and on network failures or the 10-second request timeout. Errors thrown inside the callback stop expect.poll; its 60-second timeout does not make it retry failed requests.

The test has a separate 90-second timeout so registration and browser verification have room around the email wait.

GET /api/v1/messages returns message summaries in a messages array. Once we have the numeric id, GET /api/v1/message/:id returns the full message, including body.text and body.html. The DevMail API documentation covers authentication and links to the endpoint schemas.

The regex targets Confirm Email Address (URL), a layout some HTML-to-text converters produce. It allows whitespace, including a line break, between the label and ( and preserves parentheses inside the URL. The URL itself must remain unbroken; adapt the parser to your template. If body.text is empty, parse body.html and select the confirmation anchor's href; this snippet requires plain text.

Finally, the browser opens the extracted URL and checks the application's verified state. The separate HTTP context keeps the DevMail API key scoped to mailbox requests, and finally disposes that context even if an assertion fails.

Try it in your own suite

This pattern also works for password resets, invitations, and email OTPs: trigger the action, find the message for that execution, read its content, and complete the browser flow.

If you're testing an email-dependent journey, try DevMail and adapt the example to your app. I'd love to hear which email flows you test with Playwright and what would make the integration more useful for your team.

Top comments (1)

Collapse
 
officialmailkr profile image
오피셜메일 •

실행마다 +tag 별칭을 만들고 수신자 전체 주소와 제목을 다시 대조하는 부분이 좋네요. 병렬 테스트에서 단순히 ‘가장 최근 메일’을 고르면 다른 실행의 인증 링크를 집을 수 있으니까요. 여기에 같은 링크의 재사용·만료 처리까지 별도 부정 테스트로 묶으면, 정상 가입뿐 아니라 인증 토큰의 수명과 1회 사용 규칙도 확인할 수 있겠습니다.