Playwright integration
Email testing with Playwright and InboxTap
InboxTap connects Playwright's browser actions to the email produced by the real application. Its adapter owns one local SMTP/API server per worker and injects a new recipient into every test, so parallel email flows remain deterministic without a shared hosted mailbox.Match the fixture to Playwright's lifecycle
Import the adapter from inboxtap/fixtures/playwright and extend your existing Playwright test. The injected inboxTap value has worker scope, while inbox has test scope. Playwright therefore starts the local service once for a worker but still gives every test a distinct SMTP envelope recipient.
This follows Playwright's native dependency model: a fixture is prepared only when a test or another fixture needs it, and a dependency is started before its consumer and torn down after that consumer. InboxTap uses that ordering to close its verified Nodemailer transport and listeners even when a test fails.
Start the application after InboxTap
When the application needs an automatically selected SMTP port, start it as a worker fixture that depends on inboxTap. Read inboxTap.smtp while creating the process or mail transport; it contains the assigned host and port plus secure: false and ignoreTLS: true.
Playwright's webServer configuration starts before test fixtures exist. An application launched there cannot consume a port selected later by the InboxTap fixture. Use dependent fixtures for dynamic ports, or deliberately use fixed ports and start both services through webServer.
import { test as base } from "@playwright/test";
import { extendInboxTap } from "inboxtap/fixtures/playwright";
const withInboxTap = extendInboxTap(base);
export const test = withInboxTap.extend<object, { app: TestApp }>({
app: [
async ({ inboxTap }, use) => {
const app = await startTestApp({ smtp: inboxTap.smtp });
try {
await use(app);
} finally {
await app.close();
}
},
{ scope: "worker" },
],
});Drive the real email flow
Fill the application's form with inbox.address, submit it through the browser, and wait from the Node-side test for the expected link, code, or complete message. The browser never needs mailbox credentials and does not import the InboxTap SDK.
Use waitForLink() for verification, magic-link, and reset URLs; waitForCode() for numeric codes; and waitForMessage() when the assertion needs headers, HTML, or envelope recipients. Validate a captured URL's expected origin and path before asking the page to open it.
- Create the recipient inside each test or consume the injected inbox fixture.
- Set InboxTap's timeout below Playwright's test timeout so the useful email error appears first.
- Filter messages by a stable subject or link path when a template contains several URLs.
Keep parallel workers isolated
Isolation comes from the SMTP envelope recipient, not from clearing a global mailbox. Every injected inbox has a generated address, and all of its SDK calls filter on that address. Concurrent tests can therefore share a worker service without claiming one another's messages.
Do not create one module-level TestInbox for an entire suite. Also avoid global clearing while other tests are active: deleting shared server state can remove a message that another recipient is still waiting for.
Assert delivery without leaking secrets
The Playwright matcher adapter returns an extended expect object. Use it for toHaveDeliveredOnce(), toHaveRecipient(), and toContainLink() when a concise assertion is clearer than inspecting fields manually.
toHaveDeliveredOnce() observes the current mailbox snapshot; it does not wait for the first message. Await the application delivery or call waitForMessage() first when email is dispatched in the background. Matcher failures intentionally omit message bodies, recipient values, and token-bearing links.
Keep application behavior in the application test
InboxTap proves what reached the local SMTP boundary and helps extract the value needed by the browser. Your Playwright test still owns the product assertions: whether a token is single-use, whether an expired link is rejected, whether a retry creates duplicate business records, and whether the final session has the expected permissions.
Build the complete Playwright flow
See the complete browser-flow recipe, including fixed-port service startup, OTP entry, parallel workers, and troubleshooting.
Read the Playwright guide