# Test email behavior (/docs/v/0.6.1/guides/test-email-behavior)



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()` and `failingProvider()`](/docs/v/0.6.1/reference/adapter-contract) from `@opencoredev/email-sdk/testing` replace real adapters.
* The [capture plugin](/docs/v/0.6.1/plugins/built-in/capture) records lifecycle events (`beforeSend`, `afterSend`, `retry`, `error`) on the real send pipeline.

<Steps>
  <Step>
    ### Assert a send happened with the right fields [#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:

    ```ts title="welcome-email.test.ts"
    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](/docs/v/0.6.1/plugins/built-in/defaults) on the test client and assert the merged headers or reply-to landed on `message`.
  </Step>

  <Step>
    ### Assert the fallback engaged when the primary fails [#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:

    ```ts title="fallback.test.ts"
    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.
  </Step>

  <Step>
    ### Assert retry events with the capture plugin [#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:

    ```ts title="retry.test.ts"
    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`.
  </Step>

  <Step>
    ### Clear stores between tests [#clear-stores-between-tests]

    Both stores are plain arrays behind a `clear()` method. Reset them in `beforeEach` when a client outlives a single test:

    ```ts
    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:

    ```ts
    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`
    ```
  </Step>

  <Step>
    ### Run the suite [#run-the-suite]

    ```bash
    bun test
    ```

    No 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.
  </Step>
</Steps>

## Next [#next]

<Cards>
  <Card title="Capture plugin" href="/docs/v/0.6.1/plugins/built-in/capture" description="Event shapes, shared stores, and custom client keys." />

  <Card title="Production send pipeline" href="/docs/v/0.6.1/guides/production-send-pipeline" description="The pipeline these tests protect: retries, fallback, defaults, observability." />

  <Card title="Fallbacks and retries" href="/docs/v/0.6.1/concepts/fallbacks-and-retries" description="Which errors retry, in what order routes run, and how to tune both." />
</Cards>
