From effect-ts
This skill should be used when the user asks about "Effect testing", "@effect/vitest", "it.effect", "it.live", "it.scoped", "it.layer", "it.prop", "Schema Arbitrary", "property-based testing", "fast-check", "TestClock", "testing effects", "mocking services", "test layers", "TestContext", "Effect.provide test", "time testing", "Effect test utilities", "unit testing Effect", "generating test data", "flakyTest", "test coverage", "100% coverage", "service testing", "test doubles", "mock services", or needs to understand how to test Effect-based code.
npx claudepluginhub andrueandersoncs/claude-skill-effect-ts --plugin effect-tsThis skill uses the workspace's default tool permissions.
Effect testing uses **`@effect/vitest`** as the standard test runner integration. This package provides Effect-aware test functions that handle Effect execution, scoped resources, layer composition, and TestClock injection automatically.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
Effect testing uses @effect/vitest as the standard test runner integration. This package provides Effect-aware test functions that handle Effect execution, scoped resources, layer composition, and TestClock injection automatically.
The two pillars of Effect testing that enable 100% test coverage:
Service-Oriented Architecture — Every external/effectful dependency (API calls, databases, file systems, third-party services, clocks, random number generators) MUST be wrapped in an Effect Service using Context.Tag. Tests provide test implementations via Layers, giving you complete control over all I/O and side effects.
Schema-Driven Property Testing — Since every data type has a Schema, every data type can generate test data via Arbitrary. This makes property-based testing the primary approach for verifying domain logic across thousands of automatically generated inputs.
Together, these two pillars mean: services eliminate external dependencies from tests, and Arbitrary eliminates hand-crafted test data. The result is fast, deterministic, comprehensive tests with 100% coverage.
Core testing tools:
it.effect, it.scoped, it.live, it.layer, it.prop)Context.Tag and it.layerit.prop or fast-checkit.effect)This is the most important testing pattern in Effect. Every external or effectful operation MUST be wrapped in a Service so that tests can provide a test implementation. This is how you achieve 100% test coverage without hitting real APIs, databases, or file systems.
If it makes a network call, reads from disk, talks to a database, calls a third-party API, generates random values, or performs any I/O — it MUST be behind an Effect Service.
Without services, your code is untestable because it directly depends on external systems:
// ❌ UNTESTABLE: Direct API call baked into business logic
const getUser = (id: string) =>
Effect.tryPromise({
try: () => fetch(`/api/users/${id}`).then((r) => r.json()),
catch: (error) => new NetworkError({ cause: error }),
});
// ❌ UNTESTABLE: Direct database access
const saveOrder = (order: Order) =>
Effect.tryPromise({
try: () => db.query("INSERT INTO orders ...", order),
catch: (error) => new DatabaseError({ cause: error }),
});
With services, your business logic is pure and fully testable:
// ✅ TESTABLE: Service abstraction for API calls
class UserApi extends Context.Tag("UserApi")<
UserApi,
{
readonly getUser: (id: string) => Effect.Effect<User, UserNotFound | NetworkError>;
readonly saveUser: (user: User) => Effect.Effect<void, NetworkError>;
}
>() {}
// ✅ TESTABLE: Service abstraction for database
class OrderRepository extends Context.Tag("OrderRepository")<
OrderRepository,
{
readonly save: (order: Order) => Effect.Effect<void, DatabaseError>;
readonly findById: (id: string) => Effect.Effect<Order, OrderNotFound>;
}
>() {}
// ✅ Business logic is pure — depends only on service interfaces
const processOrder = (orderId: string) =>
Effect.gen(function* () {
const userApi = yield* UserApi;
const orderRepo = yield* OrderRepository;
const order = yield* orderRepo.findById(orderId);
const user = yield* userApi.getUser(order.userId);
// ... pure business logic using service abstractions
});
Every one of these MUST be wrapped in a Context.Tag service:
| External Dependency | Service Example |
|---|---|
| REST/GraphQL API calls | UserApi, PaymentGateway, NotificationService |
| Database operations | UserRepository, OrderRepository |
| File system access | FileStorage, ConfigReader |
| Third-party SDKs | StripeClient, SendGridClient, AwsS3Client |
| Email/SMS sending | EmailService, SmsService |
| Message queues | EventPublisher, QueueConsumer |
| Caching systems | CacheService, RedisClient |
| Authentication providers | AuthProvider, TokenService |
| External clock/time | Use Effect's built-in Clock service |
| Random values | Use Effect's built-in Random service |
import { Context, Effect, Layer, Schema, Arbitrary } from "effect";
import { it, expect, layer } from "@effect/vitest";
import * as fc from "fast-check";
// 1. Define schemas for domain types
const TransactionId = Schema.String.pipe(
Schema.pattern(/^txn_[a-zA-Z0-9]{16}$/),
Schema.annotations({
arbitrary: () => (fc) => fc.stringMatching(/^txn_[a-zA-Z0-9]{16}$/),
}),
);
const PaymentStatus = Schema.Literal("succeeded", "pending", "failed");
class PaymentResult extends Schema.Class<PaymentResult>("PaymentResult")({
transactionId: TransactionId,
amount: Schema.Number.pipe(Schema.positive()),
currency: Schema.Literal("usd", "eur", "gbp"),
status: PaymentStatus,
}) {}
// 2. Define the service interface
class PaymentGateway extends Context.Tag("PaymentGateway")<
PaymentGateway,
{
readonly charge: (amount: number, currency: string) => Effect.Effect<PaymentResult, PaymentError>;
readonly refund: (transactionId: string) => Effect.Effect<void, RefundError>;
}
>() {}
// 3. Live implementation (used in production)
const PaymentGatewayLive = Layer.succeed(PaymentGateway, {
charge: (amount, currency) =>
Effect.tryPromise({
try: () => stripe.charges.create({ amount, currency }),
catch: (error) => new PaymentError({ cause: error }),
}),
refund: (transactionId) =>
Effect.tryPromise({
try: () => stripe.refunds.create({ charge: transactionId }),
catch: (error) => new RefundError({ cause: error }),
}),
});
// 4. Test implementation using Arbitrary — generates varied test data
const PaymentGatewayTest = Layer.effect(
PaymentGateway,
Effect.sync(() => ({
charge: (amount, currency) =>
Effect.succeed(
new PaymentResult({
transactionId: fc.sample(Arbitrary.make(TransactionId)(fc), 1)[0],
amount,
currency: currency as "usd" | "eur" | "gbp",
status: fc.sample(Arbitrary.make(PaymentStatus)(fc), 1)[0],
}),
),
refund: (_transactionId) => Effect.void,
})),
);
// 5. Property test with the test layer — 100% coverage, zero external calls
layer(PaymentGatewayTest)("PaymentService", (it) => {
it.effect.prop("should process payment for any valid amount", [Schema.Number.pipe(Schema.positive())], ([amount]) =>
Effect.gen(function* () {
const gateway = yield* PaymentGateway;
const result = yield* gateway.charge(amount, "usd");
expect(result.amount).toBe(amount);
expect(["succeeded", "pending", "failed"]).toContain(result.status);
}),
);
it.effect.prop("should handle refund for any transaction", [TransactionId], ([txnId]) =>
Effect.gen(function* () {
const gateway = yield* PaymentGateway;
yield* gateway.refund(txnId);
// No error = success
}),
);
});
For services that need to maintain state across operations within a test, use Layer.effect with Ref:
import { Effect, Layer, Ref, Option } from "effect";
const OrderRepositoryTest = Layer.effect(
OrderRepository,
Effect.gen(function* () {
const store = yield* Ref.make<Map<string, Order>>(new Map());
return {
save: (order: Order) => Ref.update(store, (m) => new Map(m).set(order.id, order)),
findById: (id: string) =>
Effect.gen(function* () {
const orders = yield* Ref.get(store);
return yield* Option.match(Option.fromNullable(orders.get(id)), {
onNone: () => Effect.fail(new OrderNotFound({ orderId: id })),
onSome: Effect.succeed,
}).pipe(Effect.flatten);
}),
findAll: () => Ref.get(store).pipe(Effect.map((m) => Array.from(m.values()))),
};
}),
);
Real tests often need multiple services. Compose test layers with Layer.merge and use Arbitrary for all test data:
import { Schema, Arbitrary, Effect, Layer } from "effect";
import { it, expect, layer } from "@effect/vitest";
import * as fc from "fast-check";
// Define schemas for all domain types
const UserId = Schema.String.pipe(Schema.minLength(1));
const OrderId = Schema.String.pipe(Schema.minLength(1));
class OrderItem extends Schema.Class<OrderItem>("OrderItem")({
productId: Schema.String,
price: Schema.Number.pipe(Schema.positive()),
quantity: Schema.Number.pipe(Schema.int(), Schema.positive()),
}) {}
class Order extends Schema.Class<Order>("Order")({
id: OrderId,
userId: UserId,
items: Schema.NonEmptyArray(OrderItem),
total: Schema.Number.pipe(Schema.positive()),
}) {}
// Compose all test layers
const TestEnv = Layer.merge(UserApiTest, Layer.merge(OrderRepositoryTest, PaymentGatewayTest));
layer(TestEnv)("Order Processing", (it) => {
it.effect.prop(
"should process complete order flow for any user and order",
[Arbitrary.make(UserId), Arbitrary.make(Order)],
([userId, order]) =>
Effect.gen(function* () {
const userApi = yield* UserApi;
const orderRepo = yield* OrderRepository;
const gateway = yield* PaymentGateway;
// Full integration test with ALL services mocked + generated data
const user = yield* userApi.getUser(userId);
yield* orderRepo.save(order);
const savedOrder = yield* orderRepo.findById(order.id);
const payment = yield* gateway.charge(savedOrder.total, "usd");
expect(payment.amount).toBe(savedOrder.total);
}),
);
});
This is what NOT to do. The following pattern defeats the purpose of property-based testing because it uses hard-coded values and Effect.fail("Not implemented") instead of using Arbitrary to generate varied test data:
// ❌ WRONG: Hard-coded values and "Not implemented" errors
const defaultTestLayer = Layer.mergeAll(
Layer.succeed(ImporterService, {
import: (identifier: string) =>
Effect.fail(
new HalImportError({
message: `No importer configured for: ${identifier}`,
identifier,
}),
),
}),
Layer.succeed(UserWalletService, {
// ❌ Hard-coded address - every property test gets the same value
getAddress: () => Effect.succeed("0xTestWallet1234567890123456789012345678"),
}),
Layer.succeed(BlockchainClientService, {
// ❌ Hard-coded responses - no variation across test runs
call: () => Effect.succeed("0x"),
getLogs: () => Effect.succeed([]),
// ❌ "Not implemented" - these won't be tested at all
getTransactionTrace: () => Effect.fail(new Error("Not implemented")),
}),
Layer.succeed(ContractMetadataService, {
getFunctionAbi: () => Effect.fail(new Error("Not implemented")),
getEventAbi: () => Effect.fail(new Error("Not implemented")),
}),
Layer.succeed(TransactionService, {
sendTransaction: () => Effect.fail(new Error("Not implemented")),
}),
Layer.succeed(SwapService, {
executeSwap: () => Effect.fail(new Error("Not implemented")),
}),
Layer.succeed(CodeExecutionService, {
execute: () => Effect.fail(new CodeExecutionNotImplementedError()),
}),
);
Why this is wrong:
Effect.fail(new Error("Not implemented")) - These code paths are never exercisedThe correct approach: Use Arbitrary inside Layer.effect to generate different values for each property test run:
import { Arbitrary, Schema, Effect, Layer, Ref } from "effect";
import * as fc from "fast-check";
// Define schemas for your service return types
const WalletAddress = Schema.String.pipe(
Schema.pattern(/^0x[a-fA-F0-9]{40}$/),
Schema.annotations({
arbitrary: () => (fc) => fc.hexaString({ minLength: 40, maxLength: 40 }).map((hex) => `0x${hex}`),
}),
);
const HexData = Schema.String.pipe(
Schema.pattern(/^0x[a-fA-F0-9]*$/),
Schema.annotations({
arbitrary: () => (fc) => fc.hexaString({ minLength: 0, maxLength: 64 }).map((hex) => `0x${hex}`),
}),
);
// ✅ CORRECT: Use Arbitrary to generate test data in the layer
const PropertyTestLayer = Layer.effect(
UserWalletService,
Effect.sync(() => {
// Generate a random address for THIS test run
const address = fc.sample(Arbitrary.make(WalletAddress)(fc), 1)[0];
return {
getAddress: () => Effect.succeed(address),
};
}),
);
// ✅ CORRECT: For stateful services, combine Ref with Arbitrary
const BlockchainClientTestLayer = Layer.effect(
BlockchainClientService,
Effect.gen(function* () {
// Pre-generate test data for this test run
const callResultArb = Arbitrary.make(HexData)(fc);
const logsArb = Arbitrary.make(Schema.Array(EventLog))(fc);
return {
call: () => Effect.succeed(fc.sample(callResultArb, 1)[0]),
getLogs: () => Effect.succeed(fc.sample(logsArb, 1)[0]),
// ✅ Generate valid trace data instead of failing
getTransactionTrace: () => Effect.succeed(fc.sample(Arbitrary.make(TransactionTrace)(fc), 1)[0]),
};
}),
);
// ✅ CORRECT: Full test layer with Arbitrary-generated data
const FullPropertyTestLayer = Layer.mergeAll(
PropertyTestLayer,
BlockchainClientTestLayer,
// For services you actually want to test failure cases,
// use Arbitrary to generate the ERROR data too:
Layer.effect(
ImporterService,
Effect.sync(() => ({
import: (identifier: string) =>
// ✅ Either succeed with generated data OR fail with generated error
fc.sample(fc.boolean(), 1)[0]
? Effect.succeed(fc.sample(Arbitrary.make(ImportResult)(fc), 1)[0])
: Effect.fail(fc.sample(Arbitrary.make(HalImportError)(fc), 1)[0]),
})),
),
);
Key principles:
Layer.effect + Effect.sync - So each test run gets fresh generated valuesThe ultimate testing pattern: service test layers + Schema Arbitrary. This lets you test business logic across thousands of generated inputs with all external dependencies controlled:
import { it, expect, layer } from "@effect/vitest";
import { Schema, Arbitrary, Effect } from "effect";
layer(TestEnv)("Order Processing Properties", (it) => {
it.effect.prop("should calculate correct total for any valid order", [Arbitrary.make(Order)], ([order]) =>
Effect.gen(function* () {
const orderRepo = yield* OrderRepository;
yield* orderRepo.save(order);
const saved = yield* orderRepo.findById(order.id);
expect(saved.total).toBe(order.items.reduce((sum, i) => sum + i.price, 0));
}),
);
it.effect.prop("should never charge negative amounts", [Arbitrary.make(Order)], ([order]) =>
Effect.gen(function* () {
const gateway = yield* PaymentGateway;
const result = yield* gateway.charge(order.total, "usd");
expect(result.amount).toBeGreaterThanOrEqual(0);
}),
);
});
Install @effect/vitest alongside vitest (v1.6.0+):
pnpm add -D vitest @effect/vitest
Enable Effect-aware equality in your test setup:
// vitest.setup.ts (or at top of test files)
import { addEqualityTesters } from "@effect/vitest";
addEqualityTesters();
Use it.effect for all Effect tests. It automatically provides TestContext (including TestClock) and runs the Effect to completion. No async/await or Effect.runPromise needed.
import { it, expect } from "@effect/vitest";
import { Effect, Schema, Arbitrary } from "effect";
// Use it.effect.prop for property-based testing with generated data
it.effect.prop("should return user for any valid id", [Schema.String.pipe(Schema.minLength(1))], ([userId]) =>
Effect.gen(function* () {
const user = yield* getUser(userId);
expect(user.id).toBe(userId);
}),
);
NEVER use plain vitest it with Effect.runPromise when it.effect is available:
// ❌ FORBIDDEN: manual Effect.runPromise in async test
import { it, expect } from "vitest";
it("should return user", async () => {
const result = await Effect.runPromise(getUser(userId));
expect(result).toBeDefined();
});
// ✅ REQUIRED: it.effect.prop handles execution + data generation automatically
import { it, expect } from "@effect/vitest";
import { Effect, Schema } from "effect";
it.effect.prop("should return user for any valid id", [Schema.String.pipe(Schema.minLength(1))], ([userId]) =>
Effect.gen(function* () {
const user = yield* getUser(userId);
expect(user.id).toBe(userId);
}),
);
Use it.scoped when your test needs a Scope (e.g., acquireRelease resources). The scope is automatically closed when the test ends.
import { it, expect } from "@effect/vitest";
import { Effect } from "effect";
it.scoped("should manage database connection", () =>
Effect.gen(function* () {
const conn = yield* acquireDbConnection; // acquireRelease resource
const result = yield* conn.query("SELECT 1");
expect(result).toBeDefined();
// Connection is automatically released when test ends
}),
);
Use it.live when you need the real runtime environment (real clock, real logger) instead of test services.
import { it, expect } from "@effect/vitest";
import { Effect } from "effect";
it.live("should measure real time", () =>
Effect.gen(function* () {
const start = Date.now();
yield* Effect.sleep("10 millis");
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(10);
}),
);
it.scopedLive combines scoped resources with the live environment.Use it.layer to provide dependencies to a group of tests. The layer is constructed once and shared across all tests in the block. Always use Layer.effect with Ref for stateful services and Arbitrary for test data.
import { it, expect, layer } from "@effect/vitest";
import { Effect, Layer, Context, Ref, Option, Schema, Arbitrary } from "effect";
import * as fc from "fast-check";
// Define User schema for Arbitrary generation
class User extends Schema.Class<User>("User")({
id: Schema.String.pipe(Schema.minLength(1)),
name: Schema.String.pipe(Schema.minLength(1)),
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
}) {}
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
userId: Schema.String,
}) {}
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: string) => Effect.Effect<User, UserNotFound>;
readonly save: (user: User) => Effect.Effect<void>;
}
>() {}
// Stateful test layer with Ref for storage
const TestUserRepo = Layer.effect(
UserRepository,
Effect.gen(function* () {
const store = yield* Ref.make<Map<string, User>>(new Map());
return {
findById: (id: string) =>
Effect.gen(function* () {
const users = yield* Ref.get(store);
return yield* Option.match(Option.fromNullable(users.get(id)), {
onNone: () => Effect.fail(new UserNotFound({ userId: id })),
onSome: Effect.succeed,
});
}).pipe(Effect.flatten),
save: (user: User) => Ref.update(store, (m) => new Map(m).set(user.id, user)),
};
}),
);
// Top-level layer wrapping a describe block
layer(TestUserRepo)("UserService", (it) => {
it.effect.prop("should save and find any valid user", [Arbitrary.make(User)], ([user]) =>
Effect.gen(function* () {
const repo = yield* UserRepository;
yield* repo.save(user);
const found = yield* repo.findById(user.id);
expect(found).toEqual(user);
}),
);
it.effect.prop("should fail for any non-existent user id", [Schema.String.pipe(Schema.minLength(1))], ([userId]) =>
Effect.gen(function* () {
const repo = yield* UserRepository;
const exit = yield* Effect.exit(repo.findById(userId));
expect(exit._tag).toBe("Failure");
}),
);
});
// Nested layers for composing dependencies
layer(TestUserRepo)("nested layers", (it) => {
it.layer(AnotherLayer)((it) => {
it.effect.prop("has both dependencies for any user", [Arbitrary.make(User)], ([user]) =>
Effect.gen(function* () {
const repo = yield* UserRepository;
const other = yield* AnotherService;
yield* repo.save(user);
// Both services available with generated data
}),
);
});
});
All test variants support standard Vitest modifiers:
it.effect.skip("temporarily disabled", () => Effect.sync(() => {}));
it.effect.only("run only this test", () => Effect.sync(() => {}));
it.effect.fails("expected to fail", () => Effect.fail(new Error("expected")));
// Conditional execution
it.effect.skipIf(process.env.CI)("skip on CI", () => Effect.sync(() => {}));
it.effect.runIf(process.env.CI)("only on CI", () => Effect.sync(() => {}));
// Parameterized tests
it.effect.each([1, 2, 3])("processes %d", (num) =>
Effect.gen(function* () {
const result = yield* processNumber(num);
expect(result).toBeGreaterThan(0);
}),
);
Use it.flakyTest for tests that may not succeed on the first attempt due to timing, external dependencies, or randomness:
import { it } from "@effect/vitest";
import { Effect } from "effect";
it.effect("should eventually succeed", () =>
it.flakyTest(
unreliableExternalCall(),
"5 seconds", // Max retry duration
),
);
it.prop integrates property-based testing directly into @effect/vitest. It accepts Schema types or fast-check Arbitraries.
import { it, expect } from "@effect/vitest";
import { Schema } from "effect";
// Array form with positional arguments
it.prop("addition is commutative", [Schema.Int, Schema.Int], ([a, b]) => {
expect(a + b).toBe(b + a);
});
// Object form with named arguments
it.prop(
"validates user fields",
{
name: Schema.NonEmptyString,
age: Schema.Number.pipe(Schema.int(), Schema.between(0, 150)),
},
({ name, age }) => {
expect(name.length).toBeGreaterThan(0);
expect(age).toBeGreaterThanOrEqual(0);
},
);
// Effect-based property test
it.effect.prop("async property", [Schema.String], ([input]) =>
Effect.gen(function* () {
const result = yield* processInput(input);
expect(result).toBeDefined();
}),
);
// With fast-check options
it.prop(
"with custom runs",
[Schema.Int],
([n]) => {
expect(n + 0).toBe(n);
},
{ fastCheck: { numRuns: 1000 } },
);
Every Schema can generate random valid test data. This is the foundation of Effect testing.
import { Schema, Arbitrary } from "effect";
import * as fc from "fast-check";
// Define your schema (you should already have this)
class User extends Schema.Class<User>("User")({
id: Schema.String.pipe(Schema.minLength(1)),
name: Schema.String.pipe(Schema.minLength(1)),
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
age: Schema.Number.pipe(Schema.int(), Schema.between(0, 150)),
}) {}
// Create an arbitrary from the schema
const UserArbitrary = Arbitrary.make(User);
// Generate test data
fc.sample(UserArbitrary(fc), 3);
// => [
// User { id: "a", name: "xyz", email: "foo@bar.com", age: 42 },
// User { id: "test", name: "b", email: "x@y.z", age: 0 },
// ...
// ]
import { Schema, Arbitrary, Match } from "effect";
import * as fc from "fast-check";
class Pending extends Schema.TaggedClass<Pending>()("Pending", {
orderId: Schema.String,
items: Schema.Array(Schema.String),
}) {}
class Shipped extends Schema.TaggedClass<Shipped>()("Shipped", {
orderId: Schema.String,
items: Schema.Array(Schema.String),
trackingNumber: Schema.String,
}) {}
class Delivered extends Schema.TaggedClass<Delivered>()("Delivered", {
orderId: Schema.String,
items: Schema.Array(Schema.String),
deliveredAt: Schema.Date,
}) {}
const Order = Schema.Union(Pending, Shipped, Delivered);
type Order = Schema.Schema.Type<typeof Order>;
// Arbitrary for the entire union - generates all variants
const OrderArbitrary = Arbitrary.make(Order);
// Arbitrary for specific variants
const PendingArbitrary = Arbitrary.make(Pending);
const ShippedArbitrary = Arbitrary.make(Shipped);
Use Schema.annotations to control generated values:
const Email = Schema.String.pipe(
Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/),
Schema.annotations({
arbitrary: () => (fc) => fc.emailAddress(), // Use fast-check's email generator
}),
);
const UserId = Schema.String.pipe(
Schema.minLength(1),
Schema.annotations({
arbitrary: () => (fc) => fc.uuid(), // Generate UUIDs
}),
);
const Age = Schema.Number.pipe(
Schema.int(),
Schema.between(18, 100),
Schema.annotations({
arbitrary: () => (fc) => fc.integer({ min: 18, max: 100 }),
}),
);
Every Schema should pass round-trip testing:
import { it, expect } from "@effect/vitest";
import { Schema, Arbitrary } from "effect";
import * as fc from "fast-check";
describe("User schema", () => {
const UserArbitrary = Arbitrary.make(User);
it.prop("should survive encode/decode round-trip", [UserArbitrary], ([user]) => {
const encoded = Schema.encodeSync(User)(user);
const decoded = Schema.decodeUnknownSync(User)(encoded);
// Verify structural equality
expect(decoded).toEqual(user);
// Verify it's still a User instance
expect(decoded).toBeInstanceOf(User);
});
});
describe("Order processing", () => {
it.prop("should handle all order states", [Arbitrary.make(Order)], ([order]) => {
// This must handle ALL variants - Match.exhaustive ensures it
const result = Match.value(order).pipe(
Match.when(Schema.is(Pending), (o) => `Pending: ${o.orderId}`),
Match.when(Schema.is(Shipped), (o) => `Shipped: ${o.trackingNumber}`),
Match.when(Schema.is(Delivered), (o) => `Delivered: ${o.deliveredAt}`),
Match.exhaustive,
);
expect(typeof result).toBe("string");
});
});
class BankAccount extends Schema.Class<BankAccount>("BankAccount")({
id: Schema.String,
balance: Schema.Number.pipe(Schema.nonNegative()),
transactions: Schema.Array(Schema.Number),
}) {
get computedBalance() {
return this.transactions.reduce((sum, t) => sum + t, 0);
}
}
describe("BankAccount invariants", () => {
it.prop("should never have negative balance", [Arbitrary.make(BankAccount)], ([account]) => {
expect(account.balance).toBeGreaterThanOrEqual(0);
});
});
const MoneyFromCents = Schema.transform(Schema.Number.pipe(Schema.int()), Schema.Number, {
decode: (cents) => cents / 100,
encode: (dollars) => Math.round(dollars * 100),
});
describe("Money transformation", () => {
it.prop("should preserve value through transform", [Schema.Int.pipe(Schema.between(0, 1000000))], ([cents]) => {
const dollars = Schema.decodeSync(MoneyFromCents)(cents);
const backToCents = Schema.encodeSync(MoneyFromCents)(dollars);
// Allow for floating point rounding
expect(backToCents).toBe(cents);
});
});
describe("Normalization is idempotent", () => {
it.prop("normalizing twice equals normalizing once", [Arbitrary.make(Email)], ([email]) => {
const once = normalizeEmail(email);
const twice = normalizeEmail(normalizeEmail(email));
expect(twice).toBe(once);
});
});
describe("Order total calculation", () => {
it.prop("total is independent of item order", { items: Schema.Array(OrderItem) }, ({ items }) => {
const total1 = calculateTotal(items);
const total2 = calculateTotal([...items].reverse());
expect(total1).toBe(total2);
});
});
Combine it.layer with Arbitrary for service-level testing — all test data comes from Schemas:
import { it, expect, layer } from "@effect/vitest";
import { Effect, Layer, Context, Ref, Option, Schema, Arbitrary } from "effect";
// Define schemas for all domain types
class User extends Schema.Class<User>("User")({
id: Schema.String.pipe(Schema.minLength(1)),
name: Schema.String.pipe(Schema.minLength(1)),
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
age: Schema.Number.pipe(Schema.int(), Schema.between(0, 150)),
}) {}
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
userId: Schema.String,
}) {}
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly save: (user: User) => Effect.Effect<void>;
readonly findById: (id: string) => Effect.Effect<User, UserNotFound>;
}
>() {}
// Stateful test layer
const TestUserRepo = Layer.effect(
UserRepository,
Effect.gen(function* () {
const usersRef = yield* Ref.make<Map<string, User>>(new Map());
return {
save: (user: User) => Ref.update(usersRef, (users) => new Map(users).set(user.id, user)),
findById: (id: string) =>
Effect.gen(function* () {
const users = yield* Ref.get(usersRef);
const user = users.get(id);
return yield* Option.match(Option.fromNullable(user), {
onNone: () => Effect.fail(new UserNotFound({ userId: id })),
onSome: Effect.succeed,
});
}).pipe(Effect.flatten),
};
}),
);
layer(TestUserRepo)("UserService", (it) => {
it.effect.prop("should save and retrieve any valid user", [Arbitrary.make(User)], ([user]) =>
Effect.gen(function* () {
const repo = yield* UserRepository;
yield* repo.save(user);
const found = yield* repo.findById(user.id);
expect(found).toEqual(user);
}),
);
it.effect.prop("should fail for any non-existent user id", [Schema.String.pipe(Schema.minLength(1))], ([userId]) =>
Effect.gen(function* () {
const repo = yield* UserRepository;
const exit = yield* Effect.exit(repo.findById(userId));
expect(exit._tag).toBe("Failure");
}),
);
});
import { it, expect } from "@effect/vitest";
import { Effect, Schema, Match, Arbitrary } from "effect";
class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
field: Schema.String,
message: Schema.String,
}) {}
class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError", { resourceId: Schema.String }) {}
const AppError = Schema.Union(ValidationError, NotFoundError);
type AppError = Schema.Schema.Type<typeof AppError>;
describe("Error handling", () => {
it.prop("should handle all error types", [Arbitrary.make(AppError)], ([error]) => {
const message = Match.value(error).pipe(
Match.tag("ValidationError", (e) => `Validation: ${e.field}`),
Match.tag("NotFoundError", (e) => `Not found: ${e.resourceId}`),
Match.exhaustive,
);
expect(typeof message).toBe("string");
});
});
Use Effect.exit within it.effect.prop to inspect success/failure outcomes with generated error data:
import { it, expect } from "@effect/vitest";
import { Effect, Exit, Cause, Option, Schema, Arbitrary } from "effect";
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
userId: Schema.String.pipe(Schema.minLength(1)),
}) {}
it.effect.prop("should fail with UserNotFound for any generated error", [Arbitrary.make(UserNotFound)], ([error]) =>
Effect.gen(function* () {
const exit = yield* Effect.exit(Effect.fail(error));
expect(Exit.isFailure(exit)).toBe(true);
Exit.match(exit, {
onFailure: (cause) => {
const failureError = Cause.failureOption(cause);
expect(Option.isSome(failureError)).toBe(true);
expect(Schema.is(UserNotFound)(Option.getOrThrow(failureError))).toBe(true);
expect(Option.getOrThrow(failureError).userId).toBe(error.userId);
},
onSuccess: () => {
throw new Error("Expected failure");
},
});
}),
);
it.effect automatically provides TestClock. No need to manually provide TestClock.layer.
import { it, expect } from "@effect/vitest";
import { Effect, TestClock, Fiber } from "effect";
it.effect("should complete after simulated delay", () =>
Effect.gen(function* () {
const fiber = yield* Effect.fork(Effect.sleep("1 hour").pipe(Effect.as("completed")));
yield* TestClock.adjust("1 hour");
const result = yield* Fiber.join(fiber);
expect(result).toBe("completed");
}),
);
it.effect("should retry 3 times", () =>
Effect.gen(function* () {
let attempts = 0;
const fiber = yield* Effect.fork(
Effect.sync(() => {
attempts++;
}).pipe(
Effect.flatMap(() => Effect.fail("error")),
Effect.retry(Schedule.spaced("1 second").pipe(Schedule.recurs(3))),
),
);
yield* TestClock.adjust("1 second");
yield* TestClock.adjust("1 second");
yield* TestClock.adjust("1 second");
yield* Fiber.join(fiber).pipe(Effect.ignore);
expect(attempts).toBe(4); // Initial + 3 retries
}),
);
import { it, expect } from "@effect/vitest";
import { Effect, Config, ConfigProvider, Layer } from "effect";
const TestConfigProvider = ConfigProvider.fromMap(
new Map([
["DATABASE_URL", "postgres://test:test@localhost/test"],
["API_KEY", "test-api-key"],
["DEBUG", "true"],
]),
);
const TestConfig = Layer.setConfigProvider(TestConfigProvider);
it.effect("should use test configuration", () =>
Effect.gen(function* () {
const dbUrl = yield* Config.string("DATABASE_URL");
const debug = yield* Config.boolean("DEBUG");
expect(dbUrl).toContain("localhost/test");
expect(debug).toBe(true);
}).pipe(Effect.provide(TestConfig)),
);
| Function | Environment | Scope | Use Case |
|---|---|---|---|
it.effect | Test (TestClock, etc.) | No | Most common; deterministic tests |
it.live | Live (real clock) | No | Tests needing real environment |
it.scoped | Test | Auto-cleanup | Tests with acquireRelease resources |
it.scopedLive | Live | Auto-cleanup | Resources + real environment |
it.layer | Test | Shared layer | Providing dependencies to test groups |
it.prop | Sync | No | Property-based testing with Schema/Arbitrary |
it.effect.prop | Test | No | Effect-based property testing |
it.flakyTest | Test | No | Retry flaky tests with timeout |
Context.Tag service. This is the foundation of testable Effect code.Layer.succeed for simple mocks, Layer.effect with Ref for stateful mocks.it.layer to provide test services - Share test layers across test blocks. Compose multiple test layers with Layer.merge.@effect/vitest for ALL Effect tests - Never use plain vitest it with Effect.runPromiseit.effect as the default - It provides TestContext automaticallyit.prop or it.effect.prop for property tests - Prefer over manual fc.assert/fc.property. Combine with service test layers for full coverage.it.effect)addEqualityTesters() - For proper Effect type equality in assertionsEffect.runPromise in tests - Use it.effect insteadit from vitest - Import from @effect/vitestit.prop._tag directly - Use Match.tag or Schema.is() (for Schema types only)it.effect does this automatically1. Define Services (Context.Tag) → Every external dependency has an interface
2. Create Test Layers → Every service has a test implementation
3. Use it.layer / layer() → Tests compose all test layers automatically
4. Use Arbitrary + it.prop → Test data is generated, not hand-crafted
5. Test error paths with Arbitrary → Error schemas generate error cases too
6. Test unions exhaustively → Match.exhaustive ensures all variants covered
This combination guarantees full coverage: services control all side effects, Arbitrary covers all data shapes, and Match.exhaustive covers all code paths.
For comprehensive testing documentation, consult ${CLAUDE_PLUGIN_ROOT}/references/llms-full.txt.
Search for these sections: