Test email behavior
Assert sends, fallback routing, and retries in unit tests with the memory adapter, failing adapter, and capture plugin — no provider account needed.
This guide tests email behavior without network calls: prove a send happened with the right fields, prove the fallback route engages when the primary fails, and prove retries fire. Everything runs in plain unit tests — the examples use bun:test, but any runner works.
Two tools cover it all, both free of provider credentials:
memoryProvider()andfailingProvider()from@opencoredev/email-sdk/testingreplace real adapters.- The capture plugin records lifecycle events (
beforeSend,afterSend,retry,error) on the real send pipeline.
Assert a send happened with the right fields
memoryProvider() registers like any adapter but stores every message in raw.sent instead of calling an API. Plugins, defaults, and validation all run, so you assert the message the provider actually received:
import { expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { memoryProvider } from "@opencoredev/email-sdk/testing";
test("sends the welcome email", async () => {
const memory = memoryProvider();
const email = createEmailClient({ adapters: [memory] });
await email.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Welcome to Acme",
text: "Your account is ready.",
});
expect(memory.raw.sent).toHaveLength(1);
expect(memory.raw.sent[0]?.message.subject).toBe("Welcome to Acme");
expect(memory.raw.sent[0]?.message.to).toBe("user@example.com");
});This also catches plugin transforms — mount a defaults plugin on the test client and assert the merged headers or reply-to landed on message.
Assert the fallback engaged when the primary fails
failingProvider("primary") throws on every send. Pair it with a memory backup and the same fallback order production uses:
import { expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { failingProvider, memoryProvider } from "@opencoredev/email-sdk/testing";
test("falls back to the backup route", async () => {
const backup = memoryProvider("backup");
const email = createEmailClient({
adapters: [failingProvider("primary"), backup],
fallback: ["backup"],
});
const response = await email.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Fallback",
text: "Hello",
});
expect(response.provider).toBe("backup");
expect(backup.raw.sent).toHaveLength(1);
});Use the same message fields production sends. A backup adapter that cannot carry one of them fails this test now instead of failing during an outage.
Assert retry events with the capture plugin
The capture plugin extends the client with email.capture.events. To see a retry event, you need an adapter that fails with a retryable error and then succeeds — a five-line inline adapter does it:
import { expect, test } from "bun:test";
import { createEmailClient, EmailProviderError } from "@opencoredev/email-sdk";
import { capturePlugin } from "@opencoredev/email-sdk/plugins/capture";
test("retries once on a transient failure", async () => {
let attempts = 0;
const flaky = {
name: "flaky",
send() {
attempts += 1;
if (attempts === 1) {
throw new EmailProviderError("Temporary failure", {
provider: "flaky",
retryable: true,
});
}
return { provider: "flaky", id: "ok" };
},
};
const email = createEmailClient({
adapters: [flaky],
retry: { retries: 1, delay: () => 0 },
plugins: [capturePlugin()],
});
await email.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Retry",
text: "Hello",
});
expect(attempts).toBe(2);
expect(email.capture.events.map((event) => event.type)).toEqual([
"beforeSend",
"retry",
"afterSend",
]);
});delay: () => 0 keeps the backoff out of your test runtime. failingProvider throws a plain Error (not retryable), so it tests fallback, not retries — retry tests need an error with retryable: true.
Clear stores between tests
Both stores are plain arrays behind a clear() method. Reset them in beforeEach when a client outlives a single test:
import { beforeEach } from "bun:test";
beforeEach(() => {
memory.raw.clear(); // memoryProvider's sent store
email.capture.clear(); // capture plugin's event store
});To assert capture events without going through the typed client extension, create the store yourself and hand it to the plugin:
import { capturePlugin, createEmailCaptureStore } from "@opencoredev/email-sdk/plugins/capture";
const store = createEmailCaptureStore();
const email = createEmailClient({
adapters: [memoryProvider()],
plugins: [capturePlugin(store)],
});
// store.events and store.clear() work without touching `email`Run the suite
bun testNo environment variables, no network. If a test makes a real HTTP call, an adapter slipped in — test clients should only register memoryProvider, failingProvider, or inline test adapters.
Next
Production send pipeline
Build one production-ready email client — retries, a compatible fallback route, shared defaults, idempotency keys, observability, tests, and CLI verification.
Create your first plugin
Build an audit-log plugin end to end — middleware that records every send and a typed client extension that exposes the log.
