Test Effect Programs

Install @effect/vitest

npm install -D @effect/vitest vitest

Basic it.effect Test

import { describe, it, expect } from "@effect/vitest"
import { Effect } from "effect"

describe("basic Effect tests", () => {
  it.effect("succeed returns the value", () =>
    Effect.gen(function*() {
      const result = yield* Effect.succeed(42)
      expect(result).toBe(42)
    })
  )

  it.effect("flatMap chains effects", () =>
    Effect.gen(function*() {
      const a = yield* Effect.succeed(10)
      const b = yield* Effect.succeed(20)
      expect(a + b).toBe(30)
    })
  )
})

it.effect runs a generator-based test. The test receives TestClock, TestConsole, and Scope automatically. Assertions work the same as regular vitest tests.

Testing Failures with Effect.exit

import { describe, it, expect } from "@effect/vitest"
import { Effect, Exit, Schema } from "effect"

class DivideError extends Schema.TaggedError<DivideError>()("DivideError", {
  message: Schema.String
}) {}

const divide = (a: number, b: number) =>
  b === 0
    ? new DivideError({ message: "Cannot divide by zero" })
    : Effect.succeed(a / b)

describe("failure tests", () => {
  it.effect("division by zero fails", () =>
    Effect.gen(function*() {
      const result = yield* Effect.exit(divide(10, 0))
      expect(result._tag).toBe("Failure")
      expect(Exit.isSuccess(result)).toBe(false)
    })
  )

  it.effect("successful division returns quotient", () =>
    Effect.gen(function*() {
      const result = yield* Effect.exit(divide(10, 2))
      expect(result._tag).toBe("Success")
      expect(Exit.isSuccess(result)).toBe(true)
    })
  )
})

Effect.exit wraps an effect so failures become values instead of throwing. Check result._tag to distinguish success ("Success") from failure ("Failure").

it.live for Real Time

import { describe, it, expect } from "@effect/vitest"
import { Effect, Console } from "effect"

describe("real time tests", () => {
  it.live("uses real clock for sleep", () =>
    Effect.gen(function*() {
      yield* Effect.sleep("50 millis")
      const ok = yield* Effect.succeed(true)
      expect(ok).toBe(true)
    })
  )
})

it.effect uses TestClock by default, which simulates time. Use it.live when you need real timers, real I/O, or real system clock behavior.

Gotcha: Effect.sleep under it.effect does not actually wait. You must advance TestClock manually (see next section). If your test seems to hang, you probably need it.live or TestClock.adjust.

TestClock for Time-Dependent Tests

import { describe, it, expect } from "@effect/vitest"
import { Effect, TestClock, Console } from "effect"

describe("scheduling tests", () => {
  it.effect("retry with delay uses simulated time", () =>
    Effect.gen(function*() {
      let attempts = 0

      const task = Effect.gen(function*() {
        attempts++
        if (attempts < 3) {
          return yield* Effect.fail("retry")
        }
        return "done"
      })

      // Fork the retry so we can advance the clock while it waits
      const fiber = yield* Effect.fork(
        task.pipe(Effect.retry({ times: 5, delay: "1 seconds" }))
      )

      // Advance simulated time past all retry delays
      yield* TestClock.adjust("3 seconds")

      const result = yield* fiber.join
      expect(result).toBe("done")
      expect(attempts).toBe(3)
    })
  )

  it.effect("repeat runs on schedule with simulated time", () =>
    Effect.gen(function*() {
      let count = 0

      const task = Effect.sync(() => { count++ })

      const fiber = yield* Effect.fork(
        task.pipe(Effect.repeat({ times: 3, interval: "1 seconds" }))
      )

      yield* TestClock.adjust("3 seconds")
      yield* fiber.join

      expect(count).toBe(3)
    })
  )
})

TestClock.adjust advances simulated time. Effects waiting on Effect.sleep or schedule delays complete instantly when you advance the clock past their delay. This makes time-based tests deterministic and fast.

Tip: Fork the effect before adjusting the clock. The fiber runs concurrently while you advance time. Without forking, the generator blocks on yield* and the clock adjustment never runs.

it.layer for Shared Service Layers

import { describe, it, expect } from "@effect/vitest"
import { Context, Effect, Layer, Schema } from "effect"

// Service definition
class Database extends Context.Service<Database, {
  query(sql: string): Effect.Effect<unknown[], never, never>
}>()("myapp/Database") {
  static readonly Test = Layer.succeed(
    Database,
    Database.of({
      query: () => Effect.succeed([{ id: 1, name: "Test User" }])
    })
  )
}

const getUser = (id: number) =>
  Effect.gen(function*() {
    const db = yield* Database
    const results = yield* db.query(`SELECT * FROM users WHERE id = ${id}`)
    return results[0]
  })

// Share the test layer across all tests in this describe block
describe.layer(Database.Test)("user queries", () => {
  it.effect("returns user by id", () =>
    Effect.gen(function*() {
      const user = yield* getUser(1)
      expect(user).toEqual({ id: 1, name: "Test User" })
    })
  )

  it.effect("query returns array", () =>
    Effect.gen(function*() {
      const db = yield* Database
      const results = yield* db.query("SELECT * FROM users")
      expect(Array.isArray(results)).toBe(true)
    })
  )
})

describe.layer(layer)(name, fn) provides a shared layer to all tests in the block. The layer is built once and reused, so expensive setup (database connections, HTTP clients) happens per group, not per test.

Testing Services with Mocks

import { describe, it, expect } from "@effect/vitest"
import { Context, Effect, Layer, Schema, Exit } from "effect"

class DatabaseError extends Schema.TaggedError<DatabaseError>()("DatabaseError", {
  message: Schema.String
}) {}

class Database extends Context.Service<Database, {
  query(sql: string): Effect.Effect<unknown[], DatabaseError>
}>()("myapp/Database") {
  static readonly SuccessMock = Layer.succeed(
    Database,
    Database.of({
      query: () => Effect.succeed([{ id: 1, name: "Alice" }])
    })
  )

  static readonly FailureMock = Layer.succeed(
    Database,
    Database.of({
      query: () => new DatabaseError({ message: "Connection refused" })
    })
  )
}

const getUser = (id: number) =>
  Effect.gen(function*() {
    const db = yield* Database
    return yield* db.query(`SELECT * FROM users WHERE id = ${id}`)
  })

describe.layer(Database.SuccessMock)("getUser success", () => {
  it.effect("returns user data", () =>
    Effect.gen(function*() {
      const result = yield* getUser(1)
      expect(result).toEqual([{ id: 1, name: "Alice" }])
    })
  )
})

describe("getUser failure", () => {
  it.effect("propagates database error", () =>
    Effect.gen(function*() {
      const result = yield* Effect.exit(getUser(1)).pipe(
        Effect.provide(Database.FailureMock)
      )
      expect(result._tag).toBe("Failure")
    })
  )
})

Define multiple mock layers for different scenarios. Use describe.layer for the common case, and Effect.provide inline for specific failure tests.

it.prop for Property Tests

import { describe, it, expect } from "@effect/vitest"
import { Effect, Schema } from "effect"

class PositiveNumber extends Schema.Class<PositiveNumber>("PositiveNumber")({
  value: Schema.Number.pipe(Schema.positive())
}) {}

describe("property tests", () => {
  it.prop("decoding a positive number round-trips", [Schema.Number.pipe(Schema.positive())], (n) => {
    const decoded = Schema.decodeUnknownSync(PositiveNumber)({ value: n })
    expect(decoded.value).toBe(n)
  })
})

it.prop runs property-based tests. Pass schema generators as the second argument. The test runs many times with random values conforming to the schemas.

Next Steps