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/test

The 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; pass pattern for 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.