Skip to content

Testing

Effect programs are values that describe work, so testing them is mostly a matter of running those values and asserting on the result. The @effect/vitest package wires Effect into Vitest so a test can simply return an Effect, and the surrounding machinery runs it, fails the test on an unexpected error, and tears down any scoped resources for you.

Because Effect models time, services, and randomness as data rather than calling into the platform directly, tests can be fully deterministic. Sleeps, timeouts, retries, and schedules are driven by a controllable TestClock instead of real wall-clock time, and the services your code depends on are swapped for in-memory test implementations through Layer.

import { assert, describe, it } from "@effect/vitest"
import { Effect } from "effect"
// A test is just an Effect. `it.effect` runs it and provides the test
// services (TestClock, TestConsole) automatically.
describe("greeting", () => {
it.effect("uppercases the name", () =>
Effect.gen(function*() {
const result = "ada".toUpperCase()
assert.strictEqual(result, "ADA")
}))
})

An Effect never does anything by itself — it is a description that the Effect runtime interprets. That description reaches for time, the console, randomness, and your application services through the context, so a test can replace any of them. it.effect does exactly this: it runs your Effect against a runtime where two services are swapped for test-friendly versions.

  • TestClock — time does not advance on its own. The clock starts at the epoch (0) and only moves when you call TestClock.adjust. A program that sleeps for an hour completes instantly once you advance the clock by an hour, with no real waiting. See TestClock.
  • TestConsoleConsole.log / Console.error calls are captured in memory instead of being printed, so you can assert on what a program logged.

Everything else (the order of fibers, retries, schedules) is driven by these deterministic services, so the same test produces the same result every run.

@effect/vitest builds on Vitest. Add both as dev dependencies:

Terminal window
pnpm add -D vitest @effect/vitest

Then import the enhanced it (and assert, describe, expect, layer, prop) from @effect/vitest instead of from vitest directly. The package re-exports everything from vitest, so this is your single entry point:

import { assert, describe, expect, it, layer, prop } from "@effect/vitest"

This it is the standard Vitest test function extended with Effect-aware methods. The most important ones:

MethodDescription
it.effectRun an Effect with the test services provided (TestClock, TestConsole).
it.liveRun an Effect against the live runtime — real clock, real console.
it.effect.eachRun the same Effect test over a table of cases.
it.effect.propProperty-based testing: generate inputs from Schema arbitraries.
it.flakyTestRetry an Effect until it succeeds (or a timeout elapses).
layer(...)Build a layer once and share its services across every test in a block.

Assertions come from the same import: the assert namespace re-exports Vitest’s assert (assert.strictEqual, assert.deepStrictEqual, assert.isTrue, …). Effect-aware helpers for Option, Result, and Exit (assertSome, assertSuccess, assertExitSuccess, …) live in @effect/vitest/utils (see the assertions reference below).

The @effect/vitest patterns you reach for most. Each has a fuller treatment on the sub-pages linked at the bottom; this is the shortlist.

import { assert, it } from "@effect/vitest"
import { Effect, Exit } from "effect"
// `it.effect` provides TestClock + TestConsole and a Scope, runs the Effect,
// and fails the test if it fails unexpectedly.
it.effect("adds", () =>
Effect.gen(function*() {
assert.strictEqual(yield* Effect.succeed(1 + 1), 2)
}))
// A failing Effect would abort the test — capture the outcome with Effect.exit
// (or Effect.result) and assert on the value instead.
it.effect("fails as expected", () =>
Effect.gen(function*() {
const exit = yield* Effect.exit(Effect.fail("DivByZero"))
assert.deepStrictEqual(exit, Exit.fail("DivByZero"))
}))
import { assert, it } from "@effect/vitest"
import { Clock, Effect } from "effect"
// Real Clock, real Console, real randomness — use it only when you must observe
// actual timing. A program that sleeps will actually wait.
it.live("reads real time", () =>
Effect.gen(function*() {
const millis = yield* Clock.currentTimeMillis
assert.isTrue(millis > 0) // => real epoch millis, not 0
}))
import { assert, it } from "@effect/vitest"
import { Effect, Fiber } from "effect"
import { TestClock } from "effect/testing"
// Fork the time-dependent work, advance the clock, then join and assert.
it.effect("completes a one-hour sleep instantly", () =>
Effect.gen(function*() {
const fiber = yield* Effect.forkChild(
Effect.sleep("1 hour").pipe(Effect.as("done" as const))
)
yield* TestClock.adjust("1 hour")
assert.strictEqual(yield* Fiber.join(fiber), "done")
}))
import { assert, it } from "@effect/vitest"
import { Effect } from "effect"
// One test per row; `%#` in the name is replaced with the case index.
it.effect.each([
{ input: "ada", expected: "ADA" },
{ input: "grace", expected: "GRACE" }
])("uppercases %#", ({ input, expected }) =>
Effect.gen(function*() {
assert.strictEqual(input.toUpperCase(), expected)
}))
import { assert, it } from "@effect/vitest"
import { Effect, Schema } from "effect"
// Inputs are generated from Schema arbitraries (array form -> positional,
// record form -> named) and checked over many random samples.
it.effect.prop("addition commutes", [Schema.Number, Schema.Number], ([a, b]) =>
Effect.gen(function*() {
assert.strictEqual(a + b, b + a)
}))
import { assert, it, layer } from "@effect/vitest"
import { Context, Effect, Layer } from "effect"
class Greeter extends Context.Service<Greeter, {
readonly hello: (name: string) => string
}>()("Greeter") {
static readonly layer = Layer.succeed(Greeter, {
hello: (name) => `Hello, ${name}!`
})
}
// `layer(...)` builds the layer once and shares it across the block; the `it`
// passed to the callback already has the layer's services in context.
layer(Greeter.layer)("Greeter", (it) => {
it.effect("greets", () =>
Effect.gen(function*() {
const greeter = yield* Greeter
assert.strictEqual(greeter.hello("Ada"), "Hello, Ada!")
}))
})
import { it } from "@effect/vitest"
import { Effect } from "effect"
// `flakyTest` is a plain combinator (not an it.* definition): it retries the
// Effect until it succeeds or the timeout elapses. Reach for it sparingly.
it.effect("eventually succeeds", () =>
it.flakyTest(
Effect.sync(() => {
if (Math.random() > 0.1) throw new Error("retry me")
}),
"5 seconds" // => Duration.Input
))

Because it.effect provides TestConsole, any Console.log / Console.error performed by your program is recorded instead of printed. Read it back with TestConsole.logLines and TestConsole.errorLines from effect/testing:

import { assert, describe, it } from "@effect/vitest"
import { Console, Effect } from "effect"
import { TestConsole } from "effect/testing"
describe("audit", () => {
it.effect("logs each step", () =>
Effect.gen(function*() {
yield* Console.log("starting")
yield* Console.error("oops")
const logs = yield* TestConsole.logLines
const errors = yield* TestConsole.errorLines
assert.deepStrictEqual(logs, ["starting"]) // => captured Console.log args
assert.deepStrictEqual(errors, ["oops"]) // => captured Console.error args
}))
})

logLines and errorLines return the original arguments passed to the console calls, flattened in call order — not formatted strings. Only code that goes through Effect’s Console service is captured; direct globalThis.console calls bypass the test console. TestConsole has no dedicated page; everything it exposes (layer, make, testConsoleWith, logLines, errorLines) is covered here.

Beyond the it.* methods above (covered on the sub-pages), @effect/vitest exports a few helpers for wiring and customizing your test setup. The package also re-exports all of vitest (describe, expect, vi, beforeEach, …), so you rarely need to import from vitest directly.

Registers equality testers so Vitest’s expect(...).toEqual(...) understands Effect’s Equal trait (e.g. two Options or Data structs compare by value). Call it once at module top level.

import { addEqualityTesters, expect, it } from "@effect/vitest"
import { Option } from "effect"
addEqualityTesters()
it("compares by Equal", () => {
expect(Option.some(1)).toEqual(Option.some(1)) // => true via Equal.equals
})

Builds the full set of Effect-aware methods (effect, live, layer, flakyTest, prop, …) from a custom Vitest it. Advanced — use it when you have a specialized Vitest TestAPI and want the @effect/vitest surface on top of it. it itself is makeMethods(vitest.it).

import { makeMethods } from "@effect/vitest"
import * as V from "vitest"
const it = makeMethods(V.it) // => { effect, live, layer, flakyTest, prop, ... }

Wraps a Vitest describe block and hands the callback a fresh set of Effect-aware methods built for that block. Advanced; useful for building custom test harnesses.

import { describeWrapped } from "@effect/vitest"
import { Effect } from "effect"
describeWrapped("suite", (it) => {
it.effect("runs", () => Effect.void) // => `it` is the Effect-aware methods
})

Assertions reference (@effect/vitest/utils)

Section titled “Assertions reference (@effect/vitest/utils)”

Value-level assertion helpers, imported from @effect/vitest/utils. They throw on failure and are meant to be called after a test has produced a value; they do not run Effects or advance the TestClock.

import * as Assert from "@effect/vitest/utils"

Fails the current test with the given message.

import { fail } from "@effect/vitest/utils"
if (false) fail("unreachable branch was reached") // => throws AssertionError

Reference / primitive equality via Node’s assert.strictEqual.

import { strictEqual } from "@effect/vitest/utils"
strictEqual(1 + 1, 2) // => passes

Deep structural equality via Node’s assert.deepStrictEqual.

import { deepStrictEqual } from "@effect/vitest/utils"
deepStrictEqual({ a: 1 }, { a: 1 }) // => passes

Asserts two values are not deeply equal.

import { notDeepStrictEqual } from "@effect/vitest/utils"
notDeepStrictEqual({ a: 1 }, { a: 2 }) // => passes

Equality using Effect’s Equal trait (falls back to a deepStrictEqual diff on failure so Vitest can show a structural diff).

import { assertEquals } from "@effect/vitest/utils"
import { Option } from "effect"
assertEquals(Option.some(1), Option.some(1)) // => passes via Equal.equals

Assert a value is exactly true / false.

import { assertFalse, assertTrue } from "@effect/vitest/utils"
assertTrue(1 < 2) // => passes
assertFalse(1 > 2) // => passes

Asserts a string includes a substring.

import { assertInclude } from "@effect/vitest/utils"
assertInclude("hello world", "world") // => passes

Asserts a string matches a regular expression.

import { assertMatch } from "@effect/vitest/utils"
assertMatch("2026-05-30", /^\d{4}-\d{2}-\d{2}$/) // => passes

Asserts a value is an instance of a constructor (and narrows its type).

import { assertInstanceOf } from "@effect/vitest/utils"
assertInstanceOf(new Error("boom"), Error) // => passes, narrows to Error

Asserts a thunk does not throw.

import { doesNotThrow } from "@effect/vitest/utils"
doesNotThrow(() => JSON.parse("{}")) // => passes

Asserts a thunk throws, optionally checking the thrown value against an Error or a validation function.

import { throws } from "@effect/vitest/utils"
throws(() => JSON.parse("{")) // => passes; threw a SyntaxError

Like throws, but for a thunk returning a rejected promise (the only async helper here).

import { throwsAsync } from "@effect/vitest/utils"
await throwsAsync(() => Promise.reject(new Error("nope"))) // => passes

Asserts an Option is Some holding a value equal to expected.

import { assertSome } from "@effect/vitest/utils"
import { Option } from "effect"
assertSome(Option.some(42), 42) // => passes

Asserts an Option is None.

import { assertNone } from "@effect/vitest/utils"
import { Option } from "effect"
assertNone(Option.none()) // => passes

Assert a value is (not) undefined; assertDefined narrows the type.

import { assertDefined, assertUndefined } from "@effect/vitest/utils"
assertDefined(1 as number | undefined) // => passes, narrows to number
assertUndefined(undefined) // => passes

Asserts a Result is Success holding a value equal to expected.

import { assertSuccess } from "@effect/vitest/utils"
import { Result } from "effect"
assertSuccess(Result.succeed(1), 1) // => passes

Asserts a Result is Failure holding an error equal to expected.

import { assertFailure } from "@effect/vitest/utils"
import { Result } from "effect"
assertFailure(Result.fail("boom"), "boom") // => passes

Asserts an Exit is a success with a value equal to expected. Pair it with Effect.exit to capture an Effect’s outcome.

import { assertExitSuccess } from "@effect/vitest/utils"
import { it } from "@effect/vitest"
import { Effect } from "effect"
it.effect("succeeds", () =>
Effect.gen(function*() {
const exit = yield* Effect.exit(Effect.succeed(1))
assertExitSuccess(exit, 1) // => passes
}))

Asserts an Exit is a failure with a Cause equal to expected.

import { assertExitFailure } from "@effect/vitest/utils"
import { it } from "@effect/vitest"
import { Cause, Effect } from "effect"
it.effect("fails", () =>
Effect.gen(function*() {
const exit = yield* Effect.exit(Effect.fail("boom"))
assertExitFailure(exit, Cause.fail("boom")) // => passes
}))

The assert namespace exported from @effect/vitest also re-exports Vitest’s own assert (assert.strictEqual, assert.deepStrictEqual, assert.isTrue, assert.instanceOf, …), so for the most common checks you can use the single @effect/vitest import and reach for @effect/vitest/utils when you need the Effect-aware helpers above.

  • Writing tests — use it.effect to run Effects, assert on results and failures, and parameterize tests with each and property-based testing.
  • TestClock — drive sleeps, timeouts, retries, and schedules forward in virtual time so time-dependent code runs instantly and deterministically.
  • Testing services — provide test implementations of your services as layers, share a layer across a block with layer(...), and inspect state through a test ref.
  • Integration testing — run your real services wired together and swap only the lowest-level dependency (e.g. back the whole stack with in-memory SQLite instead of production Postgres).
  • Schema and property testing — generate inputs from Schema arbitraries with it.effect.prop, and validate schemas round-trip with TestSchema and FastCheck.