Testing
Replace the provider with an in-memory Email layer, script its responses, and assert on every message your code tried to send.
EmailTest.layer() provides the Email service without a provider or network. It also provides an EmailTest service that records what was sent. Import it from the testing subpath, which needs only Effect.
Example (Asserting One Email Was Sent)
import { } from "effect";
import { } from "@effect-kit/emails";
import { } from "@effect-kit/emails/testing";
const = .(function* () {
const = yield* ;
const = yield* ;
yield* .({
: "hello@example.com",
: "ada@example.com",
: "Welcome",
: "Your account is ready.",
});
const messages = yield* .; return .;
}).(.(.()));messages is a snapshot of every attempted submission, in order. Each entry is a NormalizedEmailMessage: from is a mailbox object and to, cc, bcc and replyTo are arrays of mailboxes, whatever form you passed.
Example (Using the Layer in a Vitest Test)
import { Effect } from "effect";
import { expect, it } from "vitest";
import { Email } from "@effect-kit/emails";
import { EmailTest } from "@effect-kit/emails/testing";
const sendWelcome = Effect.gen(function* () {
const email = yield* Email;
return yield* email.send({
from: "hello@example.com",
to: "ada@example.com",
subject: "Welcome",
text: "Your account is ready.",
});
});
it("sends one welcome email", async () => {
const messages = await Effect.runPromise(
Effect.gen(function* () {
yield* sendWelcome;
return yield* (yield* EmailTest).messages;
}).pipe(Effect.provide(EmailTest.layer())),
);
expect(messages).toHaveLength(1);
expect(messages[0]?.subject).toBe("Welcome");
});What the layer does
EmailTest.layer goes through the same path as a real provider: it validates the message, checks capabilities and records a span. A message that fails validation or a capability check never reaches the outbox, as in production.
Each use of the layer starts with an empty outbox. Without scripted responses, every send succeeds with an accepted receipt whose reference is email-test-1, email-test-2 and so on.
Options
Prop
Type
Scripting responses
Give responses one Effect per attempt. Use Effect.succeed with a receipt for a send that works and Effect.fail with an EmailError for one that does not. A failed attempt is still recorded in the outbox.
Example (Failing the First Send, Then Succeeding)
import { } from "effect";
import { , , } from "@effect-kit/emails";
import { } from "@effect-kit/emails/testing";
const = .(
new ({
: "test",
: "send",
: "rejected",
: new ({}),
}),
);
const = .({
: "test",
: "accepted" as ,
: [{ : "providerMessageId" as , : "msg-1" }],
});
const = .(function* () {
const = yield* ;
const = yield* ;
yield*
.({
: "hello@example.com",
: "ada@example.com",
: "Welcome",
: "Your account is ready.",
})
.(.({ : 1 }));
return (yield* .).;
// Output: 2
}).(.(.({ : [, ] })));A script that runs out is a defect
If your code sends more times than responses has entries, the extra send dies with an error
saying the script was exhausted. It is never an implicit success, so a runaway retry loop fails
the test.
Testing idempotency and capabilities
The default capabilities have no idempotency, so a test that passes an idempotencyKey fails with UnsupportedFeatureError. Enable it, or restrict other fields, through capabilities.
Example (Testing Against a Provider Without Attachments)
import { EmailTest } from "@effect-kit/emails/testing";
const layer = EmailTest.layer({
provider: "mailersend",
capabilities: {
text: true,
html: true,
htmlAndText: true,
senderName: true,
cc: true,
bcc: true,
replyTo: "single",
headers: true,
attachments: false,
inlineAttachments: false,
idempotency: { mode: "unsupported" },
},
});The EmailTest service has no inbox UI and no HTTP sink. To test a provider layer itself, pass a fake client to its layerClient.