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.sleepunderit.effectdoes not actually wait. You must advanceTestClockmanually (see next section). If your test seems to hang, you probably needit.liveorTestClock.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
- Define Services - Create services worth testing
- Retry and Schedule - Schedules to test with TestClock
- Integrate with Existing Code - Bridge Effect and framework test runners