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",
});
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
Nothing is relayed to an external inbox.
Running InboxTap
Install it as a development dependency:
npm install --save-dev inboxtap
Then start the capture server:
npx inboxtap
The default endpoints are:
SMTP: localhost:1025
API: http://localhost:8025
Point your application's mail configuration at the local SMTP server:
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false
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,
},
],
});
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();
});
This test crosses the boundaries that matter:
- The application renders the email.
- The application sends it through SMTP.
- InboxTap captures and parses the raw message.
- The SDK finds the correct recipient and subject.
- Playwright opens the extracted URL.
- 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
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,
});
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.1and::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
Where does email testing cause the most friction in your stack: template rendering, SMTP configuration, or parallel-test isolation?
Top comments (1)
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.