Effect-TS patterns for Testing. Use when working with testing in Effect-TS applications.
This skill provides 10 curated Effect-TS patterns for testing. Use this skill when working on tasks related to:
Rule: Use Effect.runPromise in tests to run and assert on Effect results.
Good Example:
import { describe, it, expect } from "vitest"
import { Effect } from "effect"
// ============================================
// Code to test
// ============================================
const add = (a: number, b: number): Effect.Effect<number> =>
Effect.succeed(a + b)
const divide = (a: number, b: number): Effect.Effect<number, Error> =>
b === 0
? Effect.fail(new Error("Cannot divide by zero"))
: Effect.succeed(a / b)
const fetchUser = (id: string): Effect.Effect<{ id: string; name: string }> =>
Effect.succeed({ id, name: `User ${id}` })
// ============================================
// Tests
// ============================================
describe("Basic Effect Tests", () => {
it("should add two numbers", async () => {
const result = await Effect.runPromise(add(2, 3))
expect(result).toBe(5)
})
it("should divide numbers", async () => {
const result = await Effect.runPromise(divide(10, 2))
expect(result).toBe(5)
})
it("should fail on divide by zero", async () => {
await expect(Effect.runPromise(divide(10, 0))).rejects.toThrow(
"Cannot divide by zero"
)
})
it("should fetch a user", async () => {
const user = await Effect.runPromise(fetchUser("123"))
expect(user).toEqual({
id: "123",
name: "User 123",
})
})
})
// ============================================
// Testing Effect.gen programs
// ============================================
const calculateDiscount = (price: number, quantity: number) =>
Effect.gen(function* () {
if (price <= 0) {
return yield* Effect.fail(new Error("Invalid price"))
}
const subtotal = price * quantity
const discount = quantity >= 10 ? 0.1 : 0
const total = subtotal * (1 - discount)
return { subtotal, discount, total }
})
describe("Effect.gen Tests", () => {
it("should calculate without discount", async () => {
const result = await Effect.runPromise(calculateDiscount(10, 5))
expect(result.subtotal).toBe(50)
expect(result.discount).toBe(0)
expect(result.total).toBe(50)
})
it("should apply bulk discount", async () => {
const result = await Effect.runPromise(calculateDiscount(10, 10))
expect(result.subtotal).toBe(100)
expect(result.discount).toBe(0.1)
expect(result.total).toBe(90)
})
it("should fail for invalid price", async () => {
await expect(
Effect.runPromise(calculateDiscount(-5, 10))
).rejects.toThrow("Invalid price")
})
})
Rationale:
Test Effect programs by running them with Effect.runPromise and using standard test assertions on the results.
Testing Effect code is straightforward:
Effect.runPromise to executeRule: Provide test implementations of services to make Effect programs testable.
Good Example:
import { describe, it, expect } from "vitest"
import { Effect, Context } from "effect"
// ============================================
// 1. Define a service
// ============================================
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: string) => Effect.Effect<User | null>
readonly save: (user: User) => Effect.Effect<void>
}
>() {}
interface User {
id: string
name: string
email: string
}
// ============================================
// 2. Code that uses the service
// ============================================
const getUser = (id: string) =>
Effect.gen(function* () {
const repo = yield* UserRepository
const user = yield* repo.findById(id)
if (!user) {
return yield* Effect.fail(new Error(`User ${id} not found`))
}
return user
})
const createUser = (name: string, email: string) =>
Effect.gen(function* () {
const repo = yield* UserRepository
const user: User = {
id: crypto.randomUUID(),
name,
email,
}
yield* repo.save(user)
return user
})
// ============================================
// 3. Create a test implementation
// ============================================
const makeTestUserRepository = (initialUsers: User[] = []) => {
const users = new Map(initialUsers.map(u => [u.id, u]))
return UserRepository.of({
findById: (id) => Effect.succeed(users.get(id) ?? null),
save: (user) => Effect.sync(() => { users.set(user.id, user) }),
})
}
// ============================================
// 4. Write tests
// ============================================
describe("User Service Tests", () => {
it("should find an existing user", async () => {
const testUser: User = {
id: "123",
name: "Alice",
email: "alice@example.com",
}
const testRepo = makeTestUserRepository([testUser])
const result = await Effect.runPromise(
getUser("123").pipe(
Effect.provideService(UserRepository, testRepo)
)
)
expect(result).toEqual(testUser)
})
it("should fail when user not found", async () => {
const testRepo = makeTestUserRepository([])
await expect(
Effect.runPromise(
getUser("999").pipe(
Effect.provideService(UserRepository, testRepo)
)
)
).rejects.toThrow("User 999 not found")
})
it("should create and save a user", async () => {
const savedUsers: User[] = []
const trackingRepo = UserRepository.of({
findById: () => Effect.succeed(null),
save: (user) => Effect.sync(() => { savedUsers.push(user) }),
})
const result = await Effect.runPromise(
createUser("Bob", "bob@example.com").pipe(
Effect.provideService(UserRepository, trackingRepo)
)
)
expect(result.name).toBe("Bob")
expect(result.email).toBe("bob@example.com")
expect(savedUsers).toHaveLength(1)
expect(savedUsers[0].name).toBe("Bob")
})
})
Rationale:
When testing Effects that require services, provide test implementations using Effect.provideService or test layers.
Effect's service pattern makes testing easy:
Rule: Use the Clock service to get the current time, enabling deterministic testing with TestClock.
Good Example:
This example shows a function that checks if a token is expired. Its logic depends on Clock, making it fully testable.
import { Effect, Clock, Duration } from "effect";
interface Token {
readonly value: string;
readonly expiresAt: number; // UTC milliseconds
}
// This function is pure and testable because it depends on Clock
const isTokenExpired = (
token: Token
): Effect.Effect<boolean, never, Clock.Clock> =>
Clock.currentTimeMillis.pipe(
Effect.map((now) => now > token.expiresAt),
Effect.tap((expired) =>
Clock.currentTimeMillis.pipe(
Effect.flatMap((currentTime) =>
Effect.log(
`Token expired? ${expired} (current time: ${new Date(currentTime).toISOString()})`
)
)
)
)
);
// Create a test clock service that advances time
const makeTestClock = (timeMs: number): Clock.Clock => ({
currentTimeMillis: Effect.succeed(timeMs),
currentTimeNanos: Effect.succeed(BigInt(timeMs * 1_000_000)),
sleep: (duration: Duration.Duration) => Effect.succeed(void 0),
unsafeCurrentTimeMillis: () => timeMs,
unsafeCurrentTimeNanos: () => BigInt(timeMs * 1_000_000),
[Clock.ClockTypeId]: Clock.ClockTypeId,
});
// Create a token that expires in 1 second
const token = { value: "abc", expiresAt: Date.now() + 1000 };
// Check token expiry with different clocks
const program = Effect.gen(function* () {
// Check with current time
yield* Effect.log("Checking with current time...");
yield* isTokenExpired(token);
// Check with past time
yield* Effect.log("\nChecking with past time (1 minute ago)...");
const pastClock = makeTestClock(Date.now() - 60_000);
yield* isTokenExpired(token).pipe(
Effect.provideService(Clock.Clock, pastClock)
);
// Check with future time
yield* Effect.log("\nChecking with future time (1 hour ahead)...");
const futureClock = makeTestClock(Date.now() + 3600_000);
yield* isTokenExpired(token).pipe(
Effect.provideService(Clock.Clock, futureClock)
);
});
// Run the program with default clock
Effect.runPromise(
program.pipe(Effect.provideService(Clock.Clock, makeTestClock(Date.now())))
);
Anti-Pattern:
Directly calling Date.now() inside your business logic. This creates an impure function that cannot be tested reliably without manipulating the system clock, which is a bad practice.
import { Effect } from "effect";
interface Token {
readonly expiresAt: number;
}
// ❌ WRONG: This function's behavior changes every millisecond.
const isTokenExpiredUnsafely = (token: Token): Effect.Effect<boolean> =>
Effect.sync(() => Date.now() > token.expiresAt);
// Testing this function would require complex mocking of global APIs
// or would be non-deterministic.
Rationale:
Whenever you need to get the current time within an Effect, do not call Date.now() directly. Instead, depend on the Clock service and use one of its methods, such as Clock.currentTimeMillis.
Directly calling Date.now() makes your code impure and tightly coupled to the system clock. This makes testing difficult and unreliable, as the output of your function will change every time it's run.
The Clock service is Effect's solution to this problem. It's an abstraction for "the current time."
Live Clock implementation uses the real system time.TestClock layer. This gives you a virtual clock that you can manually control, allowing you to set the time to a specific value or advance it by a specific duration.This makes any time-dependent logic pure, deterministic, and easy to test with perfect precision.
Rule: Write tests that adapt to application code.
Good Example:
import { Effect } from "effect";
// Define our types
interface User {
id: number;
name: string;
}
class NotFoundError extends Error {
readonly _tag = "NotFoundError";
constructor(readonly id: number) {
super(`User ${id} not found`);
}
}
// Define database service interface
interface DatabaseServiceApi {
getUserById: (id: number) => Effect.Effect<User, NotFoundError>;
}
// Implement the service with mock data
class DatabaseService extends Effect.Service<DatabaseService>()(
"DatabaseService",
{
sync: () => ({
getUserById: (id: number) => {
// Simulate database lookup
if (id === 404) {
return Effect.fail(new NotFoundError(id));
}
return Effect.succeed({ id, name: `User ${id}` });
},
}),
}
) {}
// Test service implementation for testing
class TestDatabaseService extends Effect.Service<TestDatabaseService>()(
"TestDatabaseService",
{
sync: () => ({
getUserById: (id: number) => {
// Test data with predictable responses
const testUsers = [
{ id: 1, name: "Test User 1" },
{ id: 2, name: "Test User 2" },
{ id: 123, name: "User 123" },
];
const user = testUsers.find((u) => u.id === id);
if (user) {
return Effect.succeed(user);
}
return Effect.fail(new NotFoundError(id));
},
}),
}
) {}
// Business logic that uses the database service
const getUserWithFallback = (id: number) =>
Effect.gen(function* () {
const db = yield* DatabaseService;
return yield* Effect.gen(function* () {
const user = yield* db.getUserById(id);
return user;
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
if (error instanceof NotFoundError) {
yield* Effect.logInfo(`User ${id} not found, using fallback`);
return { id, name: `Fallback User ${id}` };
}
return yield* Effect.fail(error);
})
)
);
});
// Create a program that demonstrates the service
const program = Effect.gen(function* () {
yield* Effect.logInfo(
"=== Writing Tests that Adapt to Application Code Demo ==="
);
const db = yield* DatabaseService;
// Example 1: Successful user lookup
yield* Effect.logInfo("\n1. Looking up existing user 123...");
const user = yield* Effect.gen(function* () {
try {
return yield* db.getUserById(123);
} catch (error) {
yield* Effect.logError(
`Failed to get user: ${error instanceof Error ? error.message : "Unknown error"}`
);
return { id: -1, name: "Error" };
}
});
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
// Example 2: Handle non-existent user with proper error handling
yield* Effect.logInfo("\n2. Looking up non-existent user 404...");
const notFoundUser = yield* Effect.gen(function* () {
try {
return yield* db.getUserById(404);
} catch (error) {
if (error instanceof NotFoundError) {
yield* Effect.logInfo(
`✅ Properly handled NotFoundError: ${error.message}`
);
return { id: 404, name: "Not Found" };
}
yield* Effect.logError(
`Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
);
return { id: -1, name: "Error" };
}
});
yield* Effect.logInfo(`Result: ${JSON.stringify(notFoundUser)}`);
// Example 3: Business logic with fallback
yield* Effect.logInfo("\n3. Business logic with fallback for missing user:");
const userWithFallback = yield* getUserWithFallback(999);
yield* Effect.logInfo(
`User with fallback: ${JSON.stringify(userWithFallback)}`
);
// Example 4: Testing with different service implementation
yield* Effect.logInfo("\n4. Testing with test service implementation:");
yield* Effect.provide(
Effect.gen(function* () {
const testDb = yield* TestDatabaseService;
// Test existing user
const testUser1 = yield* Effect.gen(function* () {
try {
return yield* testDb.getUserById(1);
} catch (error) {
yield* Effect.logError(
`Test failed: ${error instanceof Error ? error.message : "Unknown error"}`
);
return { id: -1, name: "Test Error" };
}
});
yield* Effect.logInfo(`Test user 1: ${JSON.stringify(testUser1)}`);
// Test non-existing user
const testUser404 = yield* Effect.gen(function* () {
try {
return yield* testDb.getUserById(404);
} catch (error) {
yield* Effect.logInfo(
`✅ Test service properly threw NotFoundError: ${error instanceof Error ? error.message : "Unknown error"}`
);
return { id: 404, name: "Test Not Found" };
}
});
yield* Effect.logInfo(`Test result: ${JSON.stringify(testUser404)}`);
}),
TestDatabaseService.Default
);
yield* Effect.logInfo(
"\n✅ Tests that adapt to application code demonstration completed!"
);
yield* Effect.logInfo(
"The same business logic works with different service implementations!"
);
});
// Run the program with the default database service
Effect.runPromise(
Effect.provide(program, DatabaseService.Default) as Effect.Effect<
void,
never,
never
>
);
Explanation:
Tests should reflect the real interface and behavior of your code, not force changes to it.
Anti-Pattern:
Any action where the test dictates a change to the application code. Do not modify a service file to add a method just because a test needs it. If a test fails, fix the test.
Rationale:
Tests are secondary artifacts that serve to validate the application. The application's code and interfaces are the source of truth. When a test fails, fix the test's logic or setup, not the production code.
Treating application code as immutable during testing prevents the introduction of bugs and false test confidence. The goal of a test is to verify real-world behavior; changing that behavior to suit the test invalidates its purpose.
Rule: Use the auto-generated .Default layer in tests.
Good Example:
import { Effect } from "effect";
// Define MyService using Effect.Service pattern
class MyService extends Effect.Service<MyService>()("MyService", {
sync: () => ({
doSomething: () =>
Effect.succeed("done").pipe(
Effect.tap(() => Effect.log("MyService did something!"))
),
}),
}) {}
// Create a program that uses MyService
const program = Effect.gen(function* () {
yield* Effect.log("Getting MyService...");
const service = yield* MyService;
yield* Effect.log("Calling doSomething()...");
const result = yield* service.doSomething();
yield* Effect.log(`Result: ${result}`);
});
// Run the program with default service implementation
Effect.runPromise(Effect.provide(program, MyService.Default));
Explanation:
This approach ensures your tests are idiomatic, maintainable, and take full advantage of Effect's dependency injection system.
Anti-Pattern:
Do not create manual layers for your service in tests (Layer.succeed(...)) or try to provide the service class directly. This bypasses the intended dependency injection mechanism.
Rationale:
In your tests, provide service dependencies using the static .Default property that Effect.Service automatically attaches to your service class.
The .Default layer is the canonical way to provide a service in a test environment. It's automatically created, correctly scoped, and handles resolving any transitive dependencies, making tests cleaner and more robust.
Rule: Provide mock service implementations via a test-specific Layer to isolate the unit under test.
Good Example:
We want to test a Notifier service that uses an EmailClient to send emails. In our test, we provide a mock EmailClient that doesn't actually send emails but just returns a success value.
import { Effect, Layer } from "effect";
// --- The Services ---
interface EmailClientService {
send: (address: string, body: string) => Effect.Effect<void>;
}
class EmailClient extends Effect.Service<EmailClientService>()("EmailClient", {
sync: () => ({
send: (address: string, body: string) =>
Effect.sync(() => Effect.log(`Sending email to ${address}: ${body}`)),
}),
}) {}
interface NotifierService {
notifyUser: (userId: number, message: string) => Effect.Effect<void>;
}
class Notifier extends Effect.Service<NotifierService>()("Notifier", {
effect: Effect.gen(function* () {
const emailClient = yield* EmailClient;
return {
notifyUser: (userId: number, message: string) =>
emailClient.send(`user-${userId}@example.com`, message),
};
}),
dependencies: [EmailClient.Default],
}) {}
// Create a program that uses the Notifier service
const program = Effect.gen(function* () {
yield* Effect.log("Using default EmailClient implementation...");
const notifier = yield* Notifier;
yield* notifier.notifyUser(123, "Your invoice is ready.");
// Create mock EmailClient that logs differently
yield* Effect.log("\nUsing mock EmailClient implementation...");
const mockEmailClient = Layer.succeed(EmailClient, {
send: (address: string, body: string) =>
// Directly return the Effect.log without nesting it in Effect.sync
Effect.log(`MOCK: Would send to ${address} with body: ${body}`),
} as EmailClientService);
// Run the same notification with mock client
yield* Effect.gen(function* () {
const notifier = yield* Notifier;
yield* notifier.notifyUser(123, "Your invoice is ready.");
}).pipe(Effect.provide(mockEmailClient));
});
// Run the program
Effect.runPromise(Effect.provide(program, Notifier.Default));
Anti-Pattern:
Testing your business logic using the "live" implementation of its dependencies. This creates an integration test, not a unit test. It will be slow, unreliable, and may have real-world side effects (like actually sending an email).
import { Effect } from "effect";
import { NotifierLive } from "./somewhere";
import { EmailClientLive } from "./somewhere"; // The REAL email client
// ❌ WRONG: This test will try to send a real email.
it("sends a real email", () =>
Effect.gen(function* () {
const notifier = yield* Notifier;
yield* notifier.notifyUser(123, "This is a test email!");
}).pipe(
Effect.provide(NotifierLive),
Effect.provide(EmailClientLive), // Using the live layer makes this an integration test
Effect.runPromise
));
Rationale:
To test a piece of code in isolation, identify its service dependencies and provide mock implementations for them using a test-specific Layer. The most common way to create a mock layer is with Layer.succeed(ServiceTag, mockImplementation).
The primary goal of a unit test is to verify the logic of a single unit of code, independent of its external dependencies. Effect's dependency injection system is designed to make this easy and type-safe.
By providing a mock Layer in your test, you replace a real dependency (like an HttpClient that makes network calls) with a fake one that returns predictable data. This provides several key benefits:
Rule: Organize services into modular Layers that are composed hierarchically to manage complexity in large applications.
Good Example:
This example shows a BaseLayer with a Logger, a UserModule that uses the Logger, and a final AppLayer that wires them together.
// src/core/Logger.ts
import { Effect } from "effect";
export class Logger extends Effect.Service<Logger>()("App/Core/Logger", {
sync: () => ({
log: (msg: string) => Effect.log(`[LOG] ${msg}`),
}),
}) {}
// src/features/User/UserRepository.ts
export class UserRepository extends Effect.Service<UserRepository>()(
"App/User/UserRepository",
{
// Define implementation that uses Logger
effect: Effect.gen(function* () {
const logger = yield* Logger;
return {
findById: (id: number) =>
Effect.gen(func
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add PaulJPhilp/effect-patterns-testing下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer