Guides
Test emails with Bun, Vitest, and Playwright
Use lifecycle fixtures, native assertion matchers, redacted reports, and SMTP faults with Bun, Vitest, and Playwright.InboxTap ships optional, runner-native fixtures for Bun test, Vitest, and Playwright. Each fixture starts the local SMTP and HTTP listeners on automatically selected ports, creates a ready-to-use Nodemailer transport, and guarantees cleanup.
Install optional peers
Install only the runner adapter that your project uses. Nodemailer 9 is required by every fixture; Vitest 4.1 and Playwright 1.61 are optional peers of their respective subpaths.
bun add --dev inboxtap nodemailer
bun add --dev vitest
# or
bun add --dev @playwright/testThe fixture dependencies stay behind isolated package subpaths. Importing inboxtap or
inboxtap/client does not require Nodemailer, Vitest, or Playwright.
Shared fixture
Use startInboxTapFixture() when a runner has its own lifecycle model. Both ports default to 0,
so the operating system chooses free ports. Startup verifies the Nodemailer transport before
returning, partial failures are cleaned up, and close() is safe to call more than once.
import { startInboxTapFixture } from "inboxtap/fixtures";
const inboxTap = await startInboxTapFixture();
try {
const inbox = await inboxTap.createInbox({ alias: "signup" });
await inboxTap.transport.sendMail({
from: "app@local.test",
to: inbox.address,
subject: "Verify your account",
text: "Open https://app.local.test/verify?id=example",
});
await inbox.waitForMessage({ subject: /verify your account/i });
} finally {
await inboxTap.close();
}The returned object also exposes server, client, and smtp connection details for applications
that need to start against the selected ports.
Bun test
setupInboxTap() registers Bun's asynchronous setup and teardown hooks. Bun does not inject custom
fixture context, so create a fresh inbox explicitly inside every test.
import { expect, test } from "bun:test";
import { setupInboxTap } from "inboxtap/fixtures/bun";
const inboxTap = setupInboxTap();
test("captures one email", async () => {
const inbox = await inboxTap.createInbox({ alias: "signup" });
await inboxTap.transport.sendMail({
from: "app@local.test",
to: inbox.address,
subject: "Verify your account",
text: "Open https://app.local.test/verify?id=example",
});
const message = await inbox.waitForMessage({ subject: /verify your account/i });
expect(message.envelope.to).toContain(inbox.address);
});Vitest
extendInboxTap() adds a file-scoped inboxTap fixture and a test-scoped inbox fixture to a
Vitest base test. Every test receives a new address while the file reuses one verified server and
transport.
import { expect, test as base } from "vitest";
import { extendInboxTap } from "inboxtap/fixtures/vitest";
const test = extendInboxTap(base);
test("captures one email", async ({ inboxTap, inbox }) => {
await inboxTap.transport.sendMail({
from: "app@local.test",
to: inbox.address,
subject: "Verify your account",
text: "Open https://app.local.test/verify?id=example",
});
const message = await inbox.waitForMessage({ subject: /verify your account/i });
expect(message.envelope.to).toContain(inbox.address);
});Playwright
The Playwright adapter provides a worker-scoped inboxTap fixture and a test-scoped inbox
fixture. Each worker gets its own dynamic ports, and each test gets its own recipient address.
import { expect, test as base } from "@playwright/test";
import { extendInboxTap } from "inboxtap/fixtures/playwright";
const test = extendInboxTap(base);
test("captures a verification link", async ({ inboxTap, inbox }) => {
await inboxTap.transport.sendMail({
from: "app@local.test",
to: inbox.address,
subject: "Verify your account",
text: "Open https://app.local.test/verify?id=example",
});
const link = await inbox.waitForLink({ subject: /verify your account/i });
expect(link).toContain("/verify");
});If the application needs the dynamic SMTP port, start it as another worker fixture that depends on
inboxTap, then read inboxTap.smtp while starting the process. Playwright's webServer starts
before test fixtures, so an already-started webServer cannot consume a port selected later by the
InboxTap worker fixture.
Runner-native matchers
InboxTap keeps its matcher implementations in the peer-free inboxtap/matchers subpath and
publishes typed adapters for each runner. Inject the runner's expect into
extendInboxTapExpect(). Bun and Vitest extend that object in place:
import { expect } from "vitest";
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";
extendInboxTapExpect(expect);The Bun setup uses the same pattern with bun:test and inboxtap/matchers/bun. Playwright's
native expect.extend() returns a new object, so export the returned value alongside your extended
test fixture:
import { expect as baseExpect, test as baseTest } from "@playwright/test";
import { extendInboxTap } from "inboxtap/fixtures/playwright";
import { extendInboxTapExpect } from "inboxtap/matchers/playwright";
export const test = extendInboxTap(baseTest);
export const expect = extendInboxTapExpect(baseExpect);The matchers work with the fixture's test-scoped inbox and captured messages:
await expect(inbox).toHaveDeliveredOnce({
subject: /verify your account/i,
quietMs: 100,
});
const email = await inbox.waitForMessage({ subject: /verify your account/i });
expect(email).toHaveRecipient(inbox.address);
expect(email).toContainLink("/verify");
expect(email).toHaveUnsubscribeHeader({ oneClick: true });toHaveDeliveredOnce() is a snapshot assertion: it checks the messages already captured and does
not wait for the first delivery. quietMs is an optional observation window that starts only after
the initial snapshot contains exactly one matching message. It can catch a retry during that
window, but it cannot prove that no later retry will arrive.
Use inboxTapMatchers directly with another compatible Jest-style expect, or create a fresh set
with createInboxTapMatchers({ recorder }). Those exports do not import a runner. The recorder
receives structured, content-safe observations; matcher diagnostics likewise omit bodies,
recipient values, links, token-bearing patterns, and raw headers.
Write a redacted test report
InboxTapReport from inboxtap/reports is both a matcher recorder and a collector for captured
messages and application-level assertions. For one non-concurrent Vitest reporting workflow,
extend the expect bound to the current test:
import { test as base } from "vitest";
import { extendInboxTap } from "inboxtap/fixtures/vitest";
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";
import { InboxTapReport } from "inboxtap/reports";
const test = extendInboxTap(base);
test("writes redacted evidence", async ({ expect, inboxTap, inbox }) => {
const report = new InboxTapReport({ title: "Signup email" });
extendInboxTapExpect(expect, { recorder: report });
try {
await inboxTap.transport.sendMail({
from: "app@local.test",
to: inbox.address,
subject: "Verify your account",
text: "Open https://app.local.test/verify/id-example?next=private",
});
await expect(inbox).toHaveDeliveredOnce({ subject: /verify/i });
const email = await inbox.waitForMessage({ subject: /verify/i });
report.addAssertion({
name: "verification email exposes one link",
passed: email.links.length === 1,
messageId: email.id,
});
} finally {
for (const email of await inbox.messages()) report.addMessage(email);
await report.write("artifacts/signup-email.json");
await report.write("artifacts/signup-email.html");
}
});Writing from finally preserves the latest evidence when a matcher or application assertion
fails.
The same ordered input produces deterministic, versioned JSON or self-contained static HTML.
write() infers the format from .json or .html and creates missing parent directories. The
default report excludes raw RFC source, pseudonymizes email addresses consistently, redacts common
secret surfaces, escapes captured HTML, and never loads remote assets. Collection stops at 100
messages and 1,000 assertions; each rendered artifact is capped at 10 MiB and records explicit
truncation. Omitted-byte accounting distinguishes exact counts from measured lower bounds.
Recorder scope follows the extended expect. Do not repeatedly attach per-test collectors to one
shared Bun or Vitest expect during concurrent execution; record messages and application
assertions explicitly, or deliberately produce one suite-level artifact. Playwright can create a
fresh returned expect for each report. Redaction remains best-effort, so review artifacts before
sharing them. includeRaw: true is a higher-risk opt-in because it retains a
best-effort-redacted copy of the raw RFC source.
Isolation and cleanup
Share the server only at the scope defined by the adapter. Never create one suite-global
TestInbox: call createInbox() inside each Bun test, or use the injected inbox in Vitest and
Playwright. Unique envelope recipients keep concurrent tests isolated without clearing another
test's messages.
All adapters stop the server and close the transport when their native scope ends. You can pass
server options to setupInboxTap(options) or extendInboxTap(baseTest, options) when the defaults
need adjustment; omitted SMTP and API ports remain dynamic.
Exercise failure paths
Use the fixture server’s fault controller to test application retries at the real SMTP boundary. Target every rule to the test’s unique envelope recipient so a concurrent transaction cannot consume it. Register the rule immediately before triggering the application.
test("retries a transient SMTP failure", async ({ inboxTap, inbox }) => {
const send = () =>
inboxTap.transport.sendMail({
from: "app@local.test",
to: inbox.address,
subject: "Verify your account",
text: "Open https://app.local.test/verify?id=example",
});
inboxTap.server.faults.failNext({
code: 451,
to: inbox.address,
});
await expect(send()).rejects.toThrow();
await expect(send()).resolves.toBeDefined();
expect(await inbox.messages()).toHaveLength(1);
});A 451 response is useful for retry and backoff paths; use a 550 response for permanent-failure
handling. delayNext() exercises application timeouts, and disconnectNext() exercises recovery
from an interrupted SMTP session. Failed and disconnected attempts never produce partial captured
messages.
For concurrency, use pauseNext() as an explicit gate instead of sleeping. Await
gate.waitUntilPaused() before launching the competing action, and call the idempotent
gate.release() in a finally block. The default 60-second absolute gate timeout still bounds a
missed match or release; if an active pause expires, SMTP returns 451 and the message is not
captured. Teardown aborts remaining gates; avoid calling reset() from a concurrent test because
it affects the shared server’s other rules.
Only one fault rule applies to a transaction. Use times when consecutive attempts need the same
failure, or register the next rule after observing the previous attempt. InboxTap controls SMTP
delivery behavior; application persistence, idempotency, and business-level deduplication remain
assertions owned by the application’s tests.
Choose the right helper
- Use
waitForLink()for verification, password-reset, and magic-link URLs. - Use
waitForCode()for numeric OTPs; passpatternfor a non-default format. - Use
waitForMatch()for an API key or another value embedded in the body. - Use
waitForMessage()when the assertion needs headers, recipients, HTML, or raw source. - Use
messages()when the email may already have arrived and you need to inspect the current set.
All wait helpers are bounded. Set timeoutMs high enough for the application flow, but keep it below
the test runner's own timeout so failures report the InboxTap context first.