DEV Community

Cover image for How to test real email flows with Playwright, without Docker or real inboxes
Yolaine
Yolaine

Posted on

How to test real email flows with Playwright, without Docker or real inboxes

Mocking sendEmail() proves that your application tried to send something.

It does not prove that:

  • The template rendered correctly
  • The SMTP handoff succeeded
  • The recipient received the right message
  • The verification URL works
  • The OTP can complete the flow

For unit tests, mocking the mailer often makes sense.

For signup verification, magic links, password resets, and invitations, I wanted to test the boundary that users actually depend on.

That is why I built InboxTap, a local-only SMTP capture server with a typed TypeScript test SDK.

A green test that never opened the email is very confident about half the journey.

The testing gap

A typical email unit test looks something like this:

expect(sendEmail).toHaveBeenCalledWith({
  to: "developer@example.com",
  template: "verify-email",
});
Enter fullscreen mode Exit fullscreen mode

Useful, but incomplete.

The application could generate an invalid URL, send the wrong code, break the HTML template, or fail during SMTP delivery. The mock would still pass.

InboxTap lets the application send a real SMTP message while keeping the entire workflow local:

Application
    |
    | SMTP localhost:1025
    v
InboxTap
    |
    | Bounded in-memory store
    v
HTTP API localhost:8025
    ^
    | Typed SDK
    |
Playwright test
Enter fullscreen mode Exit fullscreen mode

Nothing is relayed to an external inbox.

Running InboxTap

Install it as a development dependency:

npm install --save-dev inboxtap
Enter fullscreen mode Exit fullscreen mode

Then start the capture server:

npx inboxtap
Enter fullscreen mode Exit fullscreen mode

The default endpoints are:

SMTP: localhost:1025
API:  http://localhost:8025
Enter fullscreen mode Exit fullscreen mode

Point your application's mail configuration at the local SMTP server:

SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false
Enter fullscreen mode Exit fullscreen mode

InboxTap accepts arbitrary recipients, so you do not need to create accounts or register test addresses first.

Starting it with Playwright

Playwright can manage InboxTap alongside the application under test:

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

export default defineConfig({
  use: {
    baseURL: "http://localhost:3000",
  },
  webServer: [
    {
      command: "npx inboxtap",
      url: "http://localhost:8025/health",
      reuseExistingServer: !process.env.CI,
    },
    {
      command: "npm run dev",
      url: "http://localhost:3000",
      reuseExistingServer: !process.env.CI,
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

The test can now create its own isolated inbox and complete the real verification journey:

import { expect, test } from "@playwright/test";
import { InboxTapClient } from "inboxtap/client";

const inboxTap = new InboxTapClient();

test("verifies a new account through email", async ({ page }) => {
  const inbox = await inboxTap.createInbox({
    alias: "signup",
  });

  await page.goto("/signup");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", {
    name: "Create account",
  }).click();

  const verificationUrl = await inbox.waitForLink({
    subject: /verify your email/i,
    contains: "/verify",
    timeoutMs: 20_000,
  });

  await page.goto(verificationUrl);

  await expect(page.getByText(/email verified/i)).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

This test crosses the boundaries that matter:

  1. The application renders the email.
  2. The application sends it through SMTP.
  3. InboxTap captures and parses the raw message.
  4. The SDK finds the correct recipient and subject.
  5. Playwright opens the extracted URL.
  6. The application completes the verification flow.

Why every test gets a unique inbox

Shared test mailboxes become unreliable when tests run in parallel.

One test can consume another test's verification email, particularly when subjects and templates are similar.

createInbox() generates a unique address in the client:

signup-a1b2c3d4e5f6@local.test
Enter fullscreen mode Exit fullscreen mode

The server accepts the address without registration. Every test can therefore wait for messages sent to its exact recipient.

The same inbox API supports other email workflows:

const code = await inbox.waitForCode({
  subject: /security code/i,
});

const token = await inbox.waitForMatch({
  pattern: /api_key=([A-Za-z0-9_-]+)/,
});

const email = await inbox.waitForMessage({
  subject: /invitation/i,
});
Enter fullscreen mode Exit fullscreen mode

You can also inspect every captured field, including the SMTP envelope, headers, text, HTML, discovered links, codes, and raw RFC 822 source.

The intentionally boring safety constraints

InboxTap is designed for local development and automated tests, not production email.

By default, it:

  • Binds to 127.0.0.1 and ::1
  • Never relays messages externally
  • Keeps messages in memory only
  • Evicts old messages from a 100-message FIFO store
  • Limits individual messages to 5 MiB
  • Caps API waits at 60 seconds
  • Disables SMTP authentication and STARTTLS

InboxTap 1.x deliberately does not include persistence, a mailbox dashboard, attachments, webhooks, or a Docker image.

That narrow scope keeps the workflow small: start one process, point the application at it, and test the email journey through code.

The practical rule

Keep mocking email in focused unit tests.

But when an email contains something the user needs to continue, add at least one test that crosses the real SMTP boundary and uses the value inside the captured message.

Your users do not care whether sendEmail() was called.

They care whether the link works.

InboxTap is free, MIT-licensed, and open source.

Read the InboxTap documentation

View the source on GitHub

Where does email testing cause the most friction in your stack: template rendering, SMTP configuration, or parallel-test isolation?

Top comments (1)

Collapse
 
zyvop profile image
ZyVOP • Edited

The approach outlined in this article for testing email flows with Playwright is particularly impressive, as it eliminates the need for complex setups like Docker or actual inboxes, making the testing process much more efficient.

If you're looking to share your expertise with a broader audience, consider cross-posting your content to ZyVOP, where you can connect with like-minded professionals and grow your readership.