Vitest integration
Email testing with Vitest and InboxTap
InboxTap's Vitest adapter maps the expensive service lifecycle to file scope and recipient isolation to test scope. Tests receive a verified Nodemailer transport, dynamic connection settings, and a new inbox address without maintaining custom beforeAll and afterAll hooks.Use the official Vitest adapter
Install InboxTap, Nodemailer 9, and Vitest, then extend the base test from inboxtap/fixtures/vitest. The adapter starts SMTP and HTTP listeners on operating-system-assigned ports, verifies its transport before yielding, and closes partial or complete startup state when the file scope ends.
The package keeps Vitest behind an optional subpath. Importing the InboxTap root or client SDK does not load Vitest or Nodemailer into projects that do not use this fixture.
Combine fixtures and native matchers
The injected inboxTap fixture is shared only by tests in one file. Its transport is useful for testing a mail template directly, while inboxTap.smtp can configure an application instance that should exercise the full integration boundary.
Register the matcher adapter with Vitest's expect object. Await toHaveDeliveredOnce() because it can observe a bounded quiet window; message-level recipient and link matchers remain synchronous.
import { expect, test as base } from "vitest";
import { extendInboxTap } from "inboxtap/fixtures/vitest";
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";
extendInboxTapExpect(expect);
const test = extendInboxTap(base);
test.concurrent("captures one delivery", async ({ inboxTap, inbox }) => {
await inboxTap.transport.sendMail({
from: "app@local.test",
to: inbox.address,
subject: "Account",
text: "https://app.local.test/verify",
});
const email = await inbox.waitForMessage();
await expect(inbox).toHaveDeliveredOnce({ quietMs: 100 });
expect(email).toHaveRecipient(inbox.address);
expect(email).toContainLink("/verify");
});Wait before taking a delivery snapshot
toHaveDeliveredOnce() checks messages that already exist. Sending through the provided transport resolves after InboxTap accepts and stores the transaction, so the immediate snapshot in the example is valid. When the application queues email and returns earlier, first await inbox.waitForMessage(), then assert the delivery count.
quietMs can detect an extra delivery during that explicit observation window, but it cannot prove that a later retry will never occur. Longer-term idempotency remains an application-level assertion.
Run concurrent tests safely
Every test receives a generated address even when test.concurrent cases share the same file-scoped server. Recipient filtering keeps mailbox reads independent, so there is no need to serialize tests or clear global state between them.
Target SMTP fault rules to inbox.address when concurrent tests share a fixture. An untargeted next-transaction rule can legitimately be consumed by whichever delivery reaches DATA first.
Exercise failure paths at the SMTP boundary
Use inboxTap.server.faults to return a transient 451, a permanent 550, a bounded delay, a pause gate, or a chunk-granular disconnect. Failed and disconnected transactions do not create partial captured messages.
InboxTap controls delivery behavior rather than your persistence layer. Tests should still verify the application's retry limit, backoff, deduplication, user-visible state, and business records.
Scope report recorders carefully
The Vitest matcher adapter extends an expect object in place. Do not attach different per-test report collectors to one shared expect while concurrent tests are running. Use the test-bound expect supplied by Vitest, record messages explicitly, or intentionally build one suite-level report.
Use the rest of the Vitest toolkit
See every runner adapter, matcher behavior, fault controller, reporting workflow, and cleanup guarantee in one guide.
Read the test-runner guide