testing

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.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "testing" with this command: npx skills add andrueandersoncs/claude-skill-effect-ts/andrueandersoncs-claude-skill-effect-ts-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:

  1. 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.

  2. 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.Tag and it.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.prop or 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 DependencyService Example
REST/GraphQL API callsUserApi, PaymentGateway, NotificationService
Database operationsUserRepository, OrderRepository
File system accessFileStorage, ConfigReader
Third-party SDKsStripeClient, SendGridClient, AwsS3Client
Email/SMS sendingEmailService, SmsService
Message queuesEventPublisher, QueueConsumer
Caching systemsCacheService, RedisClient
Authentication providersAuthProvider, TokenService
External clock/timeUse Effect's built-in Clock service
Random valuesUse 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:

  1. Hard-coded values - Property tests run the same inputs every time, missing edge cases
  2. Effect.fail(new Error("Not implemented")) - These code paths are never exercised
  3. 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:

  1. Every service method should return Arbitrary-generated data - Not hard-coded strings
  2. Generate errors with Arbitrary too - Error schemas should produce varied error cases
  3. Use Layer.effect + Effect.sync - So each test run gets fresh generated values
  4. 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.scopedLive combines 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

FunctionEnvironmentScopeUse Case
it.effectTest (TestClock, etc.)NoMost common; deterministic tests
it.liveLive (real clock)NoTests needing real environment
it.scopedTestAuto-cleanupTests with acquireRelease resources
it.scopedLiveLiveAuto-cleanupResources + real environment
it.layerTestShared layerProviding dependencies to test groups
it.propSyncNoProperty-based testing with Schema/Arbitrary
it.effect.propTestNoEffect-based property testing
it.flakyTestTestNoRetry flaky tests with timeout

Best Practices

Do

  1. Wrap ALL external dependencies in Services - Every API call, database query, file read, and third-party SDK call MUST go through a Context.Tag service. This is the foundation of testable Effect code.
  2. Create test Layers for every Service - Every service MUST have a corresponding test implementation. Use Layer.succeed for simple mocks, Layer.effect with Ref for stateful mocks.
  3. Use it.layer to provide test services - Share test layers across test blocks. Compose multiple test layers with Layer.merge.
  4. Use @effect/vitest for ALL Effect tests - Never use plain vitest it with Effect.runPromise
  5. Use it.effect as the default - It provides TestContext automatically
  6. Use it.prop or it.effect.prop for property tests - Prefer over manual fc.assert/fc.property. Combine with service test layers for full coverage.
  7. Use Arbitrary for ALL test data - Never hand-craft test objects. Every Schema generates Arbitrary data.
  8. Combine services + Arbitrary for 100% coverage - Service test layers control all I/O; Arbitrary generates all data. Together they eliminate every testing gap.
  9. Test round-trips for every Schema - Encode/decode should be lossless
  10. Test all union variants - Use Match.exhaustive to ensure coverage
  11. Test invariants with properties - Not just specific examples
  12. Generate errors too - Use Arbitrary on error schemas
  13. Use TestClock for time - Deterministic, fast tests (automatic in it.effect)
  14. Call addEqualityTesters() - For proper Effect type equality in assertions

Don't

  1. Don't call external APIs/databases directly in business logic - Always go through a Service. Direct calls make code untestable.
  2. Don't skip writing test Layers - Every Service needs a test Layer. If a service doesn't have one, your coverage is incomplete.
  3. Don't use Effect.runPromise in tests - Use it.effect instead
  4. Don't import it from vitest - Import from @effect/vitest
  5. Don't hard-code test data - Use Arbitrary.make(YourSchema) or it.prop
  6. Don't test just "happy path" - Generate edge cases automatically
  7. Don't use ._tag directly - Use Match.tag or Schema.is() (for Schema types only)
  8. Don't skip round-trip tests - They catch serialization bugs
  9. Don't manually provide TestClock - it.effect does 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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

code style

No summary provided by upstream source.

Repository SourceNeeds Review
General

configuration

No summary provided by upstream source.

Repository SourceNeeds Review
General

traits

No summary provided by upstream source.

Repository SourceNeeds Review