testing
Testing in Effect
Overview
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:
- @effect/vitest - Effect-native test runner (
it.effect,it.scoped,it.live,it.layer,it.prop) - Effect Services + Test Layers - Replace ALL external dependencies with test doubles via
Context.Tagandit.layer - Schema.Arbitrary - Generate test data from any Schema (primary approach — never hand-craft test data)
- Property Testing - Test invariants with generated data via
it.propor fast-check - TestClock - Control time in tests (automatically provided by
it.effect)
The Service-Oriented Testing Pattern (CRITICAL)
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.
The Rule
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.
Why Services Are Required for Testing
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
});
What MUST Be a Service
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 |
Complete Service + Test Layer Pattern
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
}),
);
});
Stateful Test Layers (for Repository Testing)
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()))),
};
}),
);
Composing Multiple Test Layers
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);
}),
);
});
Anti-Pattern: Hard-Coded Service Mocks in Property Tests
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:
- Hard-coded values - Property tests run the same inputs every time, missing edge cases
Effect.fail(new Error("Not implemented"))- These code paths are never exercised- No Arbitrary - The whole point of property testing is generating varied data
The 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:
- Every service method should return Arbitrary-generated data - Not hard-coded strings
- Generate errors with Arbitrary too - Error schemas should produce varied error cases
- Use
Layer.effect+Effect.sync- So each test run gets fresh generated values - If a method "shouldn't be called" - Either generate valid data anyway, or use a schema-based error
Combining Services with Property Testing
The 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);
}),
);
});
Setup
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();
@effect/vitest - Core Test Functions
it.effect - Standard Effect Tests
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);
}),
);
it.scoped - Tests with Resource Management
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
}),
);
it.live - Tests with Live Environment
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.scopedLivecombines scoped resources with the live environment.
it.layer - Tests with Shared Layers
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
}),
);
});
});
Test Modifiers
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);
}),
);
it.flakyTest - Retry Flaky Tests
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
),
);
Property-Based Testing
it.prop - Schema-Aware Property Tests
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 } },
);
Schema.Arbitrary - Generating Test Data
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 },
// ...
// ]
With Schema.TaggedClass (Discriminated Unions)
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);
Customizing Arbitrary Generation
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 }),
}),
);
Property-Based Testing Patterns
Round-Trip Testing (Encode/Decode)
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);
});
});
Testing Discriminated Unions Exhaustively
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");
});
});
Testing Invariants
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);
});
});
Testing Transformations
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);
});
});
Testing Idempotency
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);
});
});
Testing Commutativity
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);
});
});
Testing Services with Layers
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");
}),
);
});
Testing Error Handling
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");
});
});
Testing with Exit
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");
},
});
}),
);
TestClock - Controlling Time
it.effect automatically provides TestClock. No need to manually provide TestClock.layer.
Basic Time Control
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");
}),
);
Testing Scheduled Operations
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
}),
);
Testing Configuration
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)),
);
@effect/vitest Quick Reference
| 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 |
Best Practices
Do
- Wrap ALL external dependencies in Services - Every API call, database query, file read, and third-party SDK call MUST go through a
Context.Tagservice. This is the foundation of testable Effect code. - Create test Layers for every Service - Every service MUST have a corresponding test implementation. Use
Layer.succeedfor simple mocks,Layer.effectwithReffor stateful mocks. - Use
it.layerto provide test services - Share test layers across test blocks. Compose multiple test layers withLayer.merge. - Use
@effect/vitestfor ALL Effect tests - Never use plain vitestitwithEffect.runPromise - Use
it.effectas the default - It provides TestContext automatically - Use
it.proporit.effect.propfor property tests - Prefer over manualfc.assert/fc.property. Combine with service test layers for full coverage. - Use Arbitrary for ALL test data - Never hand-craft test objects. Every Schema generates Arbitrary data.
- Combine services + Arbitrary for 100% coverage - Service test layers control all I/O; Arbitrary generates all data. Together they eliminate every testing gap.
- Test round-trips for every Schema - Encode/decode should be lossless
- Test all union variants - Use Match.exhaustive to ensure coverage
- Test invariants with properties - Not just specific examples
- Generate errors too - Use Arbitrary on error schemas
- Use TestClock for time - Deterministic, fast tests (automatic in
it.effect) - Call
addEqualityTesters()- For proper Effect type equality in assertions
Don't
- Don't call external APIs/databases directly in business logic - Always go through a Service. Direct calls make code untestable.
- Don't skip writing test Layers - Every Service needs a test Layer. If a service doesn't have one, your coverage is incomplete.
- Don't use
Effect.runPromisein tests - Useit.effectinstead - Don't import
itfromvitest- Import from@effect/vitest - Don't hard-code test data - Use Arbitrary.make(YourSchema) or
it.prop - Don't test just "happy path" - Generate edge cases automatically
- Don't use
._tagdirectly - Use Match.tag or Schema.is() (for Schema types only) - Don't skip round-trip tests - They catch serialization bugs
- Don't manually provide TestClock -
it.effectdoes this automatically
The Path to 100% Test Coverage
1. 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.
Additional Resources
For comprehensive testing documentation, consult ${CLAUDE_PLUGIN_ROOT}/references/llms-full.txt.
Search for these sections:
- "Arbitrary" for test data generation
- "TestClock" for time control
- "Testability" for testing patterns
More from andrueandersoncs/claude-skill-effect-ts
schema
This skill should be used when the user asks about "Effect Schema", "Schema.Struct", "Schema.decodeUnknown", "data validation", "parsing", "Schema.transform", "Schema filters", "Schema annotations", "JSON Schema", "Schema.Class", "Schema branded types", "encoding", "decoding", "Schema.parseJson", or needs to understand how Effect handles data validation and transformation.
13traits
This skill should be used when the user asks about "Effect Equal", "Effect Hash", "Equivalence", "Order", "structural equality", "custom equality", "comparing objects", "sorting", "Equal.equals", "Hash.hash", "Equivalence.make", "Order.lessThan", "comparable types", or needs to understand how Effect handles equality, hashing, and ordering of values.
12configuration
This skill should be used when the user asks about "Effect Config", "environment variables", "configuration management", "Config.string", "Config.number", "ConfigProvider", "Config.nested", "Config.withDefault", "Config.redacted", "sensitive values", "config validation", "loading config from JSON", "config schema", or needs to understand how Effect handles application configuration.
12concurrency
This skill should be used when the user asks about "Effect concurrency", "fibers", "Fiber", "forking", "Effect.fork", "Effect.forkDaemon", "parallel execution", "Effect.all concurrency", "Deferred", "Queue", "PubSub", "Semaphore", "Latch", "fiber interruption", "Effect.race", "Effect.raceAll", "concurrent effects", or needs to understand how Effect handles parallel and concurrent execution.
11observability
This skill should be used when the user asks about "Effect logging", "Effect.log", "Effect metrics", "Effect tracing", "spans", "telemetry", "Metric.counter", "Metric.gauge", "Metric.histogram", "OpenTelemetry", "structured logging", "log levels", "Effect.logDebug", "Effect.logInfo", "Effect.logWarning", "Effect.logError", or needs to understand how Effect handles logging, metrics, and distributed tracing.
10streams
This skill should be used when the user asks about "Effect Stream", "Stream.from", "Stream.map", "Stream.filter", "Stream.run", "streaming data", "async iteration", "Sink", "Channel", "Stream.concat", "Stream.merge", "backpressure", "Stream.fromIterable", "chunked processing", "real-time data", or needs to understand how Effect handles streaming data processing.
10