MailSlurp logo

Test email and SMS with Playwright

Documentation navigation
Search documentation

Test email and SMS in Playwright with isolated inboxes, phone waits, message conditions, OTP and link extraction, AI matching, fixtures, and CI troubleshooting.

View MarkdownAgent setup

Use Playwright to drive your application and MailSlurp to receive the email or SMS it sends. The standard MailSlurp SDK runs in the test process; no separate Playwright plugin is required.

Info: Learn the underlying techniques. The integration testing guide explains the core objects and recipes. The wait-for guide explains message matching, timeouts, and unread state.

Install and configure

In an existing Node.js Playwright project, install the MailSlurp SDK:

npm install --save-dev mailslurp-client

If you are starting a new Playwright project, install the runner and its browser first:

npm install --save-dev @playwright/test
npx playwright install chromium

Set MAILSLURP_API_KEY in your environment or CI secret settings using a key from the MailSlurp dashboard. The test below reads it through process.env; it never passes the key to the application page.

The application must actually send mail to the generated address. Keep its signup or password-reset request connected to your test backend; a mocked success response cannot prove delivery. Use your normal test environment's email provider and allow the runner to reach the MailSlurp API.

For a dedicated messaging suite, a playwright.config.ts can start with:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 180_000,
  expect: { timeout: 10_000 },
  retries: 0,
  use: { trace: 'retain-on-failure' },
});

These are separate budgets: the test allows the whole journey to finish, the MailSlurp wait below allows 60 seconds for delivery, and browser assertions allow 10 seconds for visible state changes. A transport timeout must also exceed the message wait. See Playwright's timeout settings.

Playwright for other languages uses the corresponding Java, Python, or C# SDK. The C# Playwright and NUnit SMS example shows that combination.

Complete email verification test

Save this as tests/email-verification.spec.ts. It exercises the MailSlurp playground: create an inbox, sign up, receive the verification email, extract a six-digit code, confirm the user, and sign in.

import { test, expect } from '@playwright/test';
import {
  MailSlurp,
  MatchOptionFieldEnum,
  MatchOptionShouldEnum,
} from 'mailslurp-client';

test('a new user can verify their email and sign in', async ({ page }, testInfo) => {
  test.setTimeout(180_000);
  const apiKey = process.env.MAILSLURP_API_KEY;
  if (!apiKey) throw new Error('Set MAILSLURP_API_KEY');
  const mailslurp = new MailSlurp({ apiKey });
  const inbox = await mailslurp.createInboxWithOptions({ expiresIn: 600_000 });
  const password = 'Test-password-42!';

  try {
    await page.goto('https://playground.mailslurp.com');
    await page.locator('[data-test="sign-in-create-account-link"]').click();
    await page.locator('input[name=email]').fill(inbox.emailAddress);
    await page.locator('input[name=password]').fill(password);

    const since = new Date();
    await page.locator('[data-test="sign-up-create-account-button"]').click();

    const email = await mailslurp.waitController.waitForMatchingFirstEmail({
      inboxId: inbox.id,
      since,
      timeout: 60_000,
      unreadOnly: true,
      matchOptions: {
        matches: [{
          field: MatchOptionFieldEnum.SUBJECT,
          should: MatchOptionShouldEnum.CONTAIN,
          value: 'Please confirm your email address',
        }],
      },
    });
    await testInfo.attach('mail-message', {
      body: JSON.stringify({ inboxId: inbox.id, emailId: email.id }),
      contentType: 'application/json',
    });
    expect(email.to).toContain(inbox.emailAddress);
    const match = /verification code is\s+(\d{6})\b/i.exec(email.body ?? '');
    if (!match) throw new Error(`No verification code in email ${email.id}`);
    const code = match[1];
    expect(code).toMatch(/^\d{6}$/);

    await page.locator('[data-test="confirm-sign-up-confirmation-code-input"]').fill(code);
    await page.locator('[data-test="confirm-sign-up-confirm-button"]').click();
    await page.locator('[data-test="username-input"]').fill(inbox.emailAddress);
    await page.locator('[data-test="sign-in-password-input"]').fill(password);
    await page.locator('[data-test="sign-in-sign-in-button"]').click();
    await expect(page.locator('[data-test="greetings-nav"]')).toBeVisible();
  } finally {
    // Expiry also removes the inbox if the runner is interrupted.
    try {
      await mailslurp.inboxController.deleteInbox({ inboxId: inbox.id });
    } catch {
      // Keep cleanup diagnostics separate from the original test failure.
      await testInfo.attach('mail-cleanup', {
        body: `Inbox cleanup failed for ${inbox.id}; its configured expiry remains in place.`,
        contentType: 'text/plain',
      });
    }
  }
});

Run the test with:

npx playwright test tests/email-verification.spec.ts --workers=1

Each attempt creates its own inbox, so this resource pattern also supports parallel workers. When adapting it to your app, replace the playground URL, selectors, expected subject, code format, and final application assertion. The browser flow stays within one test, so it does not depend on ordered tests or shared state.

Choose wait conditions for the message you need

Playwright's locator actions and retrying assertions wait for browser state. MailSlurp waits for a separate event: the message reaching the inbox or phone number. A button becoming clickable, an HTTP response succeeding, or the page becoming idle does not establish that delivery has completed.

Scenario Wait to use What to assert next
One expected email in a fresh inbox waitForLatestEmail Recipient, subject, and required content
Verification mixed with welcome emails waitForMatchingFirstEmail Code or link, then the application's confirmed state
Receipt and dispatch notification waitFor or waitForMatchingEmails Count, then the identity and content of each message
One SMS on a reserved phone waitForLatestSms Code format and the application's verification result
Multiple SMS types on a phone waitForSms Sender/body match, then read the full SMS by ID
Variable wording or layout AI message wait Successful evaluation, source evidence, then the browser result

Capture since immediately before the action that sends the message. unreadOnly excludes messages already read, but it does not identify your test run. Combine an isolated inbox or reserved phone with the time boundary and a subject, sender, or unique order reference. Reuse the full message returned by a wait: reading it can change unread state, so a second unread-only wait may not find it.

Wait for a count and inspect every result

This recipe expects a receipt and dispatch email whose subjects both contain the unique orderReference created by your test. It assumes mailslurp, inboxId, since, and orderReference are in scope. Import WaitForConditionsCountTypeEnum alongside the match enums used in the complete test.

const previews = await mailslurp.waitController.waitFor({
  waitForConditions: {
    inboxId, since, timeout: 60_000, unreadOnly: true,
    count: 2,
    countType: WaitForConditionsCountTypeEnum.ATLEAST,
    matches: [{
      field: MatchOptionFieldEnum.SUBJECT,
      should: MatchOptionShouldEnum.CONTAIN,
      value: orderReference,
    }],
  },
});
expect(previews.length).toBeGreaterThanOrEqual(2);
// Count waits return previews; load each full email to inspect its body.
const emails = await Promise.all(previews.map(({ id }) =>
  mailslurp.emailController.getEmail({ emailId: id })
));
expect(emails.some((email) => email.subject?.includes('Receipt'))).toBe(true);
expect(emails.some((email) => email.subject?.includes('Dispatch'))).toBe(true);

ATLEAST lets the wait finish when the minimum count is present, even if more messages arrived. It does not prove there were exactly two sends or establish delivery order. For duplicate-send checks, define a bounded observation period, then query all messages belonging to that action and assert the final count. For different destinations, use a separate wait per inbox or named AI wait-all expectations.

Search and list methods inspect messages already stored; they do not wait for a future delivery. Use wait-for conditions for arrival and search for later diagnostics or a final count check.

Extract email codes reliably

The complete test uses a local regular expression. That works well when you own a stable template: match text around the OTP instead of taking the first six digits, which could be an order number or date. Keep the captured value as a string so 004219 remains six characters.

You can also apply a capture pattern through the email API. This is the body of an async helper receiving mailslurp and emailId:

const result = await mailslurp.emailController.getEmailContentMatch({
  emailId,
  contentMatchOptions: {
    pattern: 'verification code is\\s+(\\d{6})\\b',
  },
});
// matches[0] is the whole match; matches[1] is the capture group.
const code = result.matches?.[1];
expect(code).toMatch(/^\d{6}$/);
if (!code) throw new Error(`No verification code in email ${emailId}`);
return code; // Keep leading zeros: never convert an OTP to a number.

JavaScript string patterns need doubled backslashes; regex literals such as /\d{6}/ do not. Adapt the phrase and length to your template. For HTML that splits a code across tags, query the known element's text or use structured extraction instead of relying on a pattern over raw markup. See HTML queries and code extraction.

For a password reset or magic-link flow, wait for the appropriate subject and extract the links from that full email. In the test above, replace the OTP extraction and form submission with your application's link flow. This snippet assumes mailslurp, email, and page are in scope:

const { links } = await mailslurp.emailController.getEmailLinks({ emailId: email.id });
const candidates = links.filter((link) => {
  const url = new URL(link);
  return url.origin === 'https://app.example.com' && url.pathname === '/auth/verify';
});
expect(candidates).toHaveLength(1);
await page.goto(candidates[0]);
await expect(page.getByRole('heading', { name: 'Your account' })).toBeVisible();

Replace the origin, path, and heading with your actual test application. Keep the browser context that requested the link when the authentication system requires matching cookies or state. Avoid first navigating to a link with a separate HTTP request if that would consume a single-use token.

Info: Test magic links end to end. The NextAuth example and magic-link guide demonstrate requesting an email and following the delivered link in Playwright.

Receive SMS OTPs

Provision a MailSlurp phone number and reserve it for this scenario. Set MAILSLURP_PHONE_NUMBER_ID to its resource ID. The API uses that ID to select messages; your application receives the dialable phone.phoneNumber, including its country code. Adapt the fill step if the form separates country code and national number.

Use this inside a test with page and an initialized mailslurp. Navigate to your application's phone form first, then replace the selectors with its actual controls and success state:

const phoneNumberId = process.env.MAILSLURP_PHONE_NUMBER_ID;
if (!phoneNumberId) throw new Error('Set MAILSLURP_PHONE_NUMBER_ID');
const phone = await mailslurp.phoneController.getPhoneNumber({ phoneNumberId });
// Replace these selectors with your app's phone verification controls.
await page.locator('input[name=phone]').fill(phone.phoneNumber);
const since = new Date();
await page.locator('[data-test=send-code]').click();
const sms = await mailslurp.waitController.waitForLatestSms({
  waitForSingleSmsOptions: {
    phoneNumberId: phone.id, since, timeout: 60_000, unreadOnly: true,
  },
});
const code = /\b(\d{6})\b/.exec(sms.body ?? '')?.[1];
expect(code).toMatch(/^\d{6}$/);
if (!code) throw new Error(`No verification code in SMS ${sms.id}`);
await page.locator('input[name=code]').fill(code);
await page.locator('[data-test=verify-code]').click();
await expect(page.locator('[data-test=phone-verified]')).toBeVisible();

The simple SMS pattern assumes a message with just one six-digit candidate. Match surrounding text when the body contains other numbers, or use AI extraction below when formats vary. An SMS receipt proves delivery; the final browser assertion proves that the application accepted the code.

Filter SMS by body or sender

If the same number receives several message types, replace the latest-SMS wait with this alternative. Import SmsMatchOptionFieldEnum and SmsMatchOptionShouldEnum from mailslurp-client. The phoneNumberId and since must refer to the same send action:

const previews = await mailslurp.waitController.waitForSms({
  waitForSmsConditions: {
    phoneNumberId, since, timeout: 60_000, unreadOnly: true, count: 1,
    matches: [{
      field: SmsMatchOptionFieldEnum.BODY,
      should: SmsMatchOptionShouldEnum.CONTAIN,
      value: 'Your verification code',
    }],
  },
});
expect(previews.length).toBeGreaterThan(0);
const sms = await mailslurp.smsController.getSmsMessage({ smsId: previews[0].id });

Add a FROM match for a known sender. Do not run both alternatives in sequence to consume the same unread message. Assign one phone per parallel worker, or serialize scenarios that share a number; timestamps alone cannot separate simultaneous sends. Reserve persistent numbers rather than deleting them after each test, and reset your application's account state before repeating a signup with the same number.

Info: Copy the SMS workflow. The SMS recipe includes the SDK calls. The Playwright SMS project supplies a full browser flow. For authenticator challenges, use TOTP devices.

Inspect content, attachments, and rendering

Use the received email's ID to query HTML or extract structured values, download attachments, or obtain preview URLs. Open an HTML preview in a separate page when you want to inspect its visible content without navigating away from the application session.

A browser preview checks the stored HTML. For Gmail, Outlook, Apple Mail, and other real-client output, use device previews and inspect the requested targets and screenshot results.

Match and extract with AI

Use deterministic subject and code matching for a stable template. Use a semantic match when localized or redesigned messages express the same purpose with different wording. Keep selection about message identity, such as "the signup confirmation"; put requirements such as the expected amount or expiry into assertions after selection, so a failed requirement remains visible.

Wait for the right email and extract its OTP

The following async helper body uses Playwright's request fixture, apiKey, inboxId, and the since timestamp captured before sending. Import randomUUID from node:crypto. Run it instead of the deterministic wait and regex extraction in the signup test, then fill the confirmation form with its returned code:

const response = await request.post('https://api.mailslurp.com/ai/messages/wait', {
  headers: { 'x-api-key': apiKey, 'Idempotency-Key': randomUUID() },
  timeout: 130_000,
  data: {
    scope: { inboxIds: [inboxId] },
    since: since.toISOString(), timeout: 120_000,
    match: { prompt: 'The account signup email containing the current confirmation code' },
    extractionPreset: 'OTP_CODE',
  },
});
expect(response.ok(), `MailSlurp HTTP ${response.status()}`).toBe(true);
const result = await response.json();
expect(result.successful, `${result.status}: ${result.summary} (${result.evaluationId})`).toBe(true);
expect(result.data?.code).toMatch(/^\d{6}$/);
expect(result.extractionEvidence?.['/code']?.length).toBeGreaterThan(0);
return result.data.code as string;

OTP_CODE returns data.code as a string. For a phone, change the scope to { phoneNumberIds: [phone.id] }. For a message already returned by another wait, call /ai/messages/extract with message: { type: 'EMAIL', id: email.id } or type: 'SMS' instead of searching again.

An HTTP success response alone does not pass the test. Check successful, the code's format, and its /code extraction evidence before submitting it. An ambiguous code can produce INCONCLUSIVE; preserve that failure rather than retrying until the model produces a passing result. Record evaluationId, status, and usage diagnostics. The complete AI Playwright project includes the surrounding browser flow.

Extract structured fields from a known email

For password-reset emails, extract the exact URL and expiry phrase into named fields. This helper body assumes request, apiKey, emailId, and appOrigin are in scope and uses the same randomUUID import:

const response = await request.post('https://api.mailslurp.com/ai/messages/extract', {
  headers: { 'x-api-key': apiKey, 'Idempotency-Key': randomUUID() },
  timeout: 70_000,
  data: {
    message: { type: 'EMAIL', id: emailId }, timeout: 60_000,
    outputSchema: {
      type: 'OBJECT', required: ['resetUrl', 'expiryText'],
      properties: {
        resetUrl: { type: 'STRING', description: 'The exact password reset URL for the recipient' },
        expiryText: { type: 'STRING', description: 'The exact phrase stating when the reset link expires' },
      },
    },
  },
});
expect(response.ok(), `MailSlurp HTTP ${response.status()}`).toBe(true);
const result = await response.json();
expect(result.successful, `${result.status}: ${result.summary} (${result.evaluationId})`).toBe(true);
expect(result.extractionEvidence?.['/resetUrl']?.length).toBeGreaterThan(0);
expect(result.extractionEvidence?.['/expiryText']?.length).toBeGreaterThan(0);
const resetUrl = new URL(result.data.resetUrl);
expect(resetUrl.origin).toBe(appOrigin);
expect(resetUrl.pathname).toBe('/auth/reset-password');
return result.data;

Set appOrigin and the expected reset path to your application's real destination. After extraction, compare expiryText with the intended policy, open resetUrl in the requesting browser context, submit a new password, and verify a fresh sign-in. Schema validity alone does not prove the link works or that the expiry statement is correct.

Use exactly one of extractionPreset, outputSchema, or transformId. For receipts, a schema can require an order ID, total, currency, and line items; compare those fields with the order your test created. Named semantic assertions and source-evidence handling are covered in the AI testing guide.

Fixtures, retries, and CI

For a complete example that separates browser actions from message retrieval, follow Using Page Object Model (POM) for E2E MFA testing. It covers email and SMS signup verification, AI code extraction, and adapting the pattern to a login MFA challenge.

Move inbox creation and teardown into a test-scoped fixture when several tests need the same setup. Each test and retry should get a fresh inbox; keep the complete signup and confirmation journey in one test. A worker-scoped phone fixture can reserve a persistent number, provided scenarios on that worker run without competing for messages.

Save this as tests/message-fixtures.ts:

import { test as base, expect } from '@playwright/test';
import { MailSlurp, type InboxDto } from 'mailslurp-client';

export const test = base.extend<{ mailslurp: MailSlurp; inbox: InboxDto }>({
  mailslurp: async ({}, use) => {
    const apiKey = process.env.MAILSLURP_API_KEY;
    if (!apiKey) throw new Error('Set MAILSLURP_API_KEY');
    await use(new MailSlurp({ apiKey }));
  },
  inbox: async ({ mailslurp }, use, testInfo) => {
    const inbox = await mailslurp.createInboxWithOptions({ expiresIn: 600_000 });
    try {
      await use(inbox);
    } finally {
      try {
        await mailslurp.inboxController.deleteInbox({ inboxId: inbox.id });
      } catch {
        await testInfo.attach('mail-cleanup', {
          body: `Inbox cleanup failed for ${inbox.id}; configured expiry remains in place.`,
          contentType: 'text/plain',
        });
      }
    }
  },
});
export { expect };

Import { test, expect } from ./message-fixtures in a test file and request async ({ page, mailslurp, inbox }). Remove that test's inline client/inbox creation and deletion; the fixture now owns them. See Playwright fixtures for composing additional test resources.

Set the test timeout to cover setup, message waits, browser actions, and cleanup. Configure the SDK's HTTP transport timeout separately when needed. Keep since immediately before the application's send action, use meaningful subject matching, and reuse the full email object after reading it.

On failure, collect inbox/message IDs and the expected filter alongside Playwright traces. Keep OTPs, full bodies, and authentication links out of broadly shared logs. Clean up your application's test user separately if its lifecycle requires it; deleting the inbox does not delete that user.

Info: Diagnose a failed wait. The wait troubleshooting table separates stale messages, read-state changes, mismatched filters, HTTP timeouts, and missing sends.

Failure What to check
Test ends before the message wait completes Increase the overall test budget and check the HTTP transport timeout separately.
Wait returns a previous code Create a fresh inbox, set since before the send, and isolate shared phone usage.
API has the email but the wait times out Inspect subject/sender filters and whether another read changed unread state.
Code is extracted but rejected Check expiry, resend invalidation, leading zeros, and that the message belongs to this account.
Works alone but fails with parallel workers Remove shared inboxes, phone reservations, accounts, and ordered-test dependencies.
AI request is HTTP 2xx but the test fails Inspect successful, status, summary, and evaluationId; do not treat inconclusive output as data.

For resend tests, establish a new timestamp before clicking resend, wait for that action's message, and assert the application's documented behavior for both the new and previous codes. Do not assume resend always rotates a code. For expiry tests, control expiry in the test environment where possible instead of adding long sleeps to the suite.

Example projects and video walkthrough

Browse the Playwright projects for email, SMS, and recorded browser flows. Use the complete test above as the starting pattern for isolation, matching, and cleanup.

Watch the email testing walkthrough alongside the code:

Open video on YouTube

Reserve numbers for parallel SMS tests

Use phone pools and leases when multiple workers need numbers. Acquire a lease in setup, pass the number into your page object, wait using its phone ID and a fresh since timestamp, and release the lease in teardown. See examples by task for related recipes.