testing
π―Skillfrom andrueandersoncs/claude-skill-effect-ts
Enables comprehensive Effect-based testing through service mocking, property-based testing, and automatic test data generation using Vitest and Schema Arbitrary.
Part of
andrueandersoncs/claude-skill-effect-ts(21 items)
Installation
/plugin marketplace add andrueandersoncs/claude-skill-effect-ts/plugin install effect-ts@effect-tsSkill Details
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.
Overview
# 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:
```typescript
// β 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:
```typescript
// β TESTABLE: Service abstraction for API calls
class UserApi extends Context.Tag("UserApi")<
UserApi,
{
readonly getUser: (id: string) => Effect.Effect
readonly saveUser: (user: User) => Effect.Effect
}
>() {}
// β TESTABLE: Service abstraction for database
class OrderRepository extends Context.Tag("OrderRepository")<
OrderRepository,
{
readonly save: (order: Order) => Effect.Effect
readonly findById: (id: string) => Effect.Effect
}
>() {}
// β 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
```typescript
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
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
readonly refund: (transactionId: string) => Effect.Effect
}
>() {}
// 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:
```typescript
import { Effect, Layer, Ref, Option } from "effect"
const OrderRepositoryTest = Layer.effect(
OrderRepository,
Effect.gen(function* () {
const store = yield* Ref.make
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:
```typescript
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
productId: Schema.String,
price: Schema.Number.pipe(Schema.positive()),
quantity: Schema.Number.pipe(Schema.int(), Schema.positive())
}) {}
class Order extends Schema.Class
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:
```typescript
// β 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:
```typescript
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:
```typescript
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+):
```bash
pnpm add -D vitest @effect/vitest
```
Enable Effect-aware equality in your test setup:
```typescript
// 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.
```typescript
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:
```typescript
// β 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.
```typescript
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.
```typescript
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.
```typescript
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
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
userId: Schema.String
}) {}
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: string) => Effect.Effect
readonly save: (user: User) => Effect.Effect
}
>() {}
// Stateful test layer with Ref for storage
const TestUserRepo = Layer.effect(
UserRepository,
Effect.gen(function* () {
const store = yield* Ref.make
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:
```typescript
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:
```typescript
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.
```typescript
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.
```typescript
import { Schema, Arbitrary } from "effect"
import * as fc from "fast-check"
// Define your schema (you should already have this)
class User extends Schema.Class
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)
```typescript
import { Schema, Arbitrary, Match } from "effect"
import * as fc from "fast-check"
class Pending extends Schema.TaggedClass
orderId: Schema.String,
items: Schema.Array(Schema.String)
}) {}
class Shipped extends Schema.TaggedClass
orderId: Schema.String,
items: Schema.Array(Schema.String),
trackingNumber: Schema.String
}) {}
class Delivered extends Schema.TaggedClass
orderId: Schema.String,
items: Schema.Array(Schema.String),
deliveredAt: Schema.Date
}) {}
const Order = Schema.Union(Pending, Shipped, Delivered)
type Order = Schema.Schema.Type
// 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:
```typescript
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:
```typescript
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
```typescript
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
```typescript
class BankAccount extends Schema.Class
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
```typescript
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
```typescript
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
```typescript
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:
```typescript
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
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
userId: Schema.String
}) {}
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly save: (user: User) => Effect.Effect
readonly findById: (id: string) => Effect.Effect
}
>() {}
// Stateful test layer
const TestUserRepo = Layer.effect(
UserRepository,
Effect.gen(function* () {
const usersRef = yield* Ref.make
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
```typescript
import { it, expect } from "@effect/vitest"
import { Effect, Schema, Match, Arbitrary } from "effect"
class ValidationError extends Schema.TaggedError
"ValidationError",
{ field: Schema.String, message: Schema.String }
) {}
class NotFoundError extends Schema.TaggedError
"NotFoundError",
{ resourceId: Schema.String }
) {}
const AppError = Schema.Union(ValidationError, NotFoundError)
type AppError = Schema.Schema.Type
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:
```typescript
import { it, expect } from "@effect/vitest"
import { Effect, Exit, Cause, Option, Schema, Arbitrary } from "effect"
class UserNotFound extends Schema.TaggedError
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
```typescript
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
```typescript
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
```typescript
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
```
- Define Services (Context.Tag) β Every external dependency has an interface
- Create Test Layers β Every service has a test implementation
- Use it.layer / layer() β Tests compose all test layers automatically
- Use Arbitrary + it.prop β Test data is generated, not hand-crafted
- Test error paths with Arbitrary β Error schemas generate error cases too
- 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 this repository10
configuration skill from andrueandersoncs/claude-skill-effect-ts
api documentation lookup skill from andrueandersoncs/claude-skill-effect-ts
effect core skill from andrueandersoncs/claude-skill-effect-ts
Skill
traits skill from andrueandersoncs/claude-skill-effect-ts
resource management skill from andrueandersoncs/claude-skill-effect-ts
Validates, transforms, and decodes data with type-safe schemas, enabling robust runtime type checking and bidirectional data transformations.
requirements management skill from andrueandersoncs/claude-skill-effect-ts
Provides guidance and code generation for managing mutable state in Effect-TS using Ref, SynchronizedRef, and idiomatic state manipulation patterns.
Enables comprehensive observability in Effect applications through structured logging, metrics tracking, and distributed tracing with seamless integration.