Testing Effect Programs Using Effect Services

Avatar of Hemanta SundarayHemanta Sundaray

Remember the non-Effect version of handleForgotPassword? It looked like this:

email.ts
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
export class SendPasswordResetEmailError extends Error {
constructor(cause: unknown) {
super("Failed to send the password reset email");
this.name = "SendPasswordResetEmailError";
this.cause = cause;
}
}
const sesClient = new SESClient({
region: "us-east-1",
credentials: {
accessKeyId: "your-access-key-id",
secretAccessKey: "your-secret-access-key",
},
});
export async function sendPasswordResetEmail(to: string, resetLink: string) {
try {
await sesClient.send(
new SendEmailCommand({
Destination: { ToAddresses: [to] },
Message: {
Body: {
Html: {
Charset: "UTF-8",
Data: `
<h1>Password Reset</h1>
<p>Click the link below to reset your password:</p>
<a href="${resetLink}">Reset Password</a>
`,
},
},
Subject: {
Charset: "UTF-8",
Data: "Reset Your Password",
},
},
Source: "noreply@yourapp.com",
}),
);
return { success: true };
} catch (error) {
return new SendPasswordResetEmailError(error);
}
}
export async function handleForgotPassword(email: string) {
const resetLink = `https://yourapp.com/reset?token=agr38b`;
return sendPasswordResetEmail(email, resetLink);
}

If we had to test the handleForgotPassword function for the following two scenarios:

  1. Email was sent successfully to the right recipient.
  2. Email sending failed.

using vitest, the test would look something like this:

email.spec.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleForgotPassword, SendPasswordResetEmailError } from "./email.js";
const sendMock = vi.hoisted(() => vi.fn());
vi.mock("@aws-sdk/client-ses", () => ({
SESClient: class {
send = sendMock;
},
SendEmailCommand: class {
constructor(readonly input: unknown) {}
},
}));
describe("handleForgotPassword", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends the email to the correct recipient", async () => {
sendMock.mockResolvedValueOnce({ MessageId: "fake-id" });
const result = await handleForgotPassword("user@example.com");
expect(result).toEqual({ success: true });
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
Destination: { ToAddresses: ["user@example.com"] },
}),
}),
);
});
it("returns a SendPasswordResetEmailError when email sending fails", async () => {
sendMock.mockRejectedValueOnce(new Error("SES is down"));
const result = await handleForgotPassword("user@example.com");
expect(result).toBeInstanceOf(SendPasswordResetEmailError);
});
});

Look at what we had to do. We used vi.mock() to replace the entire @aws-sdk/client-ses module. We created a mock for SESClient and its .send() method. We used beforeEach with vi.clearAllMocks() to reset shared mock state between tests. To verify the recipient, we had to reach into vi.mocked(SendEmailCommand).mock.calls and inspect the raw arguments. Our tests know about the internal implementation details of handleForgotPassword: that it uses SESClient, that it calls .send(), that it creates a SendEmailCommand with a specific structure.

Now let’s write the tests for the Effect version of handleForgotPassword.

Setting Up

Effect tests are written using @effect/vitest, a vitest integration built for Effect. It gives you a version of it that understands Effects.

Terminal
pnpm add -D @effect/vitest vitest

it.effect

Normally in vitest, you write tests like this:

import { it } from "vitest";
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});

But our program is an Effect. We can’t just call it like a regular function. We need the runtime to execute it.

That is what it.effect does. It takes an Effect instead of a regular function, runs it using the Effect runtime, and reports the result:

import { it } from "@effect/vitest";
import { Effect } from "effect";
it.effect("does something", () =>
Effect.gen(function* () {
// your test logic here, written as an Effect
}),
);

Under the hood, it.effect calls Effect.runPromise for you. You write your test as an Effect, and the test runner takes care of executing it.

Layer.mock

To test handleForgotPassword without sending real emails, we need to replace the Email service with a fake. This is where Layer.mock comes in. It lets you create a layer with fake implementations of your service functions:

import { Layer } from "effect";
const EmailTest = Layer.mock(Email)({
sendPasswordResetEmail: (to, resetLink) => Effect.void,
});

A few things to note about Layer.mock:

You only need to implement the functions your test actually calls. If the Email service had multiple functions but your test only calls sendPasswordResetEmail, you only provide sendPasswordResetEmail.

Layer.mock does not need SESClient or any other dependency. It completely replaces the service. No SES client, no network calls, nothing.

Writing the Tests

Now let’s write the same two test scenarios for the Effect version. Here is the complete test file:

email.spec.ts
import { describe, it, expect } from "@effect/vitest";
import { Effect, Layer, Ref } from "effect";
import {
Email,
handleForgotPassword,
SendPasswordResetEmailError,
} from "./email.js";
describe("handleForgotPasswprd", () => {
// Scenario 1: email sent successfully to the right recipient
it.effect("send the email successfully to the right reciepient", () =>
Effect.gen(function* () {
const recipient = yield* Ref.make<string | null>(null);
const EmailTest = Layer.mock(Email)({
sendPasswordResetEmail: (to, resetLink) => Ref.set(recipient, to),
});
const result = yield* handleForgotPassword("user@example.com").pipe(
Effect.provide(EmailTest),
);
expect(result).toEqual({ success: true });
const sentTo = yield* Ref.get(recipient);
expect(sentTo).toBe("user@example.com");
}),
);
// Scenario 2: email sending fails
it.effect(
"fails with SendPasswordResetEmailError when email sending fails",
() =>
Effect.gen(function* () {
const EmailTest = Layer.mock(Email)({
sendPasswordResetEmail: (to, resetLink) =>
Effect.fail(
new SendPasswordResetEmailError({ cause: "SES is down" }),
),
});
const result = yield* handleForgotPassword("user@example.com").pipe(
Effect.provide(EmailTest),
Effect.exit,
);
expect(result._tag).toBe("Failure");
}),
);
});

In the first test, we need to verify that handleForgotPassword sends the email to the correct recipient. But sendPasswordResetEmail is a side effect. It sends an email and doesn’t return anything useful. So how do we check what address it was called with?

We use Ref, which is Effect’s version of a mutable variable. We create a Ref that starts as null, and our mock writes the to argument into it when called. After the program runs, we read the Ref and verify the email was addressed correctly. Each test creates its own Ref, so there is no shared state between tests.

In the second test, we provide a mock where sendPasswordResetEmail fails with a SendPasswordResetEmailError. Notice the Effect.exit in the pipeline. Without it, the failure would propagate up and it.effect would report the test itself as failed. But that failure is the behavior we want to test. Effect.exit catches the failure and wraps it in an Exit data structure, so the Effect succeeds and we can inspect the result. Think of it as the Effect equivalent of try/catch. It converts an error from “something that blows up your program” to “a value you can assert on.”

The Comparison

Now that you have seen the test code for both the non-Effect and Effect versions of handleForgotPassword, which do you think was easier to test?

It is the Effect version. We did not have to mock any modules with vi.mock(). We did not need beforeEach or vi.clearAllMocks() to reset shared state. We did not need to know that the function uses SESClient or SendEmailCommand internally. To verify the recipient, we used a simple Ref instead of inspecting what arguments the mock was called with. All we had to do was create a test layer of the Email service using Layer.mock and provide it to the program. That’s it.

This is extremely beneficial because it helps you avoid common pitfalls with module mocking: tests that break when you refactor internals, shared mock state that leaks between tests, and mocks that silently test the wrong thing because the implementation changed. With Effect Services, each test is independent, focused, and resilient to change.

With that, you now have a solid understanding of what Effect Services are, how they work, and how they make dependency management and testing straightforward.

Sign in to save progress

Stay in the loop

Get notified when new Effect Atom related content is published.