Mailfornet

Email testing in Playwright

Give every test its own real inbox, sign up through the UI, then type the verification code in or open the confirm link. No shared mailbox, no IMAP, no fixed sleeps.

Why a fresh inbox per test

Playwright runs tests in parallel. With one shared test mailbox, two workers signing up at the same moment read each other's codes, and a test that passes alone fails in CI. A new address for each test means each one reads only its own mail. It also means the "email already registered" error never comes up on the second run.

1. Set up

npm install -D @playwright/test
export MAILFORNET_API_KEY="mf_live_your_key"   # in CI, add it as a secret

2. An inbox fixture

Put this in tests/fixtures.ts. Every test that asks for inbox gets its own address, and it's deleted when the test ends:

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

const API = 'https://api.mailfornet.com/v1';
const headers = { Authorization: `Bearer ${process.env.MAILFORNET_API_KEY}` };

type Inbox = {
  address: string;
  /** Waits for a verification mail and returns its code and confirm link. */
  waitForCode(opts?: { subject?: string; from?: string; timeout?: number }): Promise<{ code: string | null; link: string | null }>;
};

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({ request }, use) => {
    const created = await request.post(`${API}/inboxes`, { headers, data: { ttlMinutes: 30 } });
    expect(created.ok()).toBeTruthy();
    const { address } = await created.json();
    const since = Date.now();

    await use({
      address,
      async waitForCode({ subject, from, timeout = 60 } = {}) {
        const res = await request.get(`${API}/inboxes/${encodeURIComponent(address)}/code`, {
          headers,
          params: { wait: Math.min(timeout, 60), since, ...(subject && { subject }), ...(from && { from }) },
          timeout: (timeout + 10) * 1000,
        });
        if (!res.ok()) throw new Error(`No verification mail for ${address}: ${await res.text()}`);
        return res.json();
      },
    });

    await request.delete(`${API}/inboxes/${encodeURIComponent(address)}`, { headers });
  },
});

export { expect };

3. Test signup with a code

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

test('new user verifies their email with a code', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('Correct-Horse-9');
  await page.getByRole('button', { name: 'Create account' }).click();

  const { code } = await inbox.waitForCode({ subject: 'Verify' });
  expect(code).toMatch(/^\d{6}$/);

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

4. Test a confirmation link

test('user confirms via the link in the email', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Sign up' }).click();

  const { link } = await inbox.waitForCode({ subject: 'Confirm' });
  expect(link).toBeTruthy();

  await page.goto(link!);
  await expect(page.getByText('Email confirmed')).toBeVisible();
});

5. Password reset

test('password reset email works', async ({ page, inbox }) => {
  await createUserViaApi(inbox.address);          // your own setup helper
  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const { link } = await inbox.waitForCode({ subject: 'Reset' });
  await page.goto(link!);
  await page.getByLabel('New password').fill('Another-Horse-7');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page).toHaveURL(/login/);
});

Checking the email itself

To check the wording, the sender or the HTML, read the full message:

const { messageId } = await inbox.waitForCode();
const mail = await (await request.get(
  `${API}/messages/${messageId}`, { headers, params: { address: inbox.address } }
)).json();

expect(mail.from).toContain('noreply@yourapp.com');
expect(mail.subject).toBe('Verify your email');
expect(mail.links.map((l) => l.url)).toContain(mail.verificationLink);

Running in CI

# .github/workflows/e2e.yml
- run: npx playwright test
  env:
    MAILFORNET_API_KEY: ${{ secrets.MAILFORNET_API_KEY }}

Related

Cypress guide · Selenium & Python · Code examples · API reference

Get a free API key