Skip to content

Writing Tests

The core idea of @effect/vitest is that a test returns an Effect. You write the body with Effect.gen, yield* the effects you want to exercise, and use assert to check the values. it.effect runs the Effect, fails the test if it fails unexpectedly, provides the test services (TestClock, TestConsole), and closes the test Scope afterwards.

import { assert, describe, it } from "@effect/vitest"
import { Effect } from "effect"
// The code under test: a small Effect-returning function.
const divide = (a: number, b: number): Effect.Effect<number, "DivByZero"> =>
b === 0 ? Effect.fail("DivByZero") : Effect.succeed(a / b)
describe("divide", () => {
// The body is an Effect. `yield*` runs the effect under test, then we
// assert on the value it produced.
it.effect("returns the quotient on success", () =>
Effect.gen(function*() {
const result = yield* divide(10, 2)
assert.strictEqual(result, 5)
}))
})

Import assert from @effect/vitest. It re-exports Vitest’s assert, so all the familiar checks are available:

import { assert } from "@effect/vitest"
assert.strictEqual(1 + 1, 2) // reference / primitive equality
assert.deepStrictEqual([1, 2], [1, 2]) // structural equality
assert.isTrue([1, 2].includes(1)) // boolean checks
assert.include("hello world", "world") // substring / element membership

For Effect data types there are dedicated helpers in @effect/vitest/utils that give clearer failure messages than unwrapping by hand — assertSome / assertNone for Option, assertSuccess / assertFailure for Result, and assertExitSuccess / assertExitFailure for Exit.

An Effect that fails would fail the surrounding test, which is not what you want when the failure is the expected outcome. Capture the outcome with Effect.exit (or Effect.result) and assert on it instead.

import { assert, describe, it } from "@effect/vitest"
import { Effect, Exit } from "effect"
const divide = (a: number, b: number): Effect.Effect<number, "DivByZero"> =>
b === 0 ? Effect.fail("DivByZero") : Effect.succeed(a / b)
describe("divide failures", () => {
it.effect("fails with DivByZero when dividing by zero", () =>
Effect.gen(function*() {
// `Effect.exit` turns failure into a value, so the test does not abort.
const exit = yield* Effect.exit(divide(1, 0))
assert.deepStrictEqual(exit, Exit.fail("DivByZero"))
}))
})

Exit distinguishes an expected fail (your typed error channel) from a die (an unexpected defect). Asserting on the whole Exit therefore checks not just that it failed but how it failed.

it.effect.each runs the same Effect body once per row in a table — ideal for table-driven tests. The %# placeholder in the test name is replaced with the case index.

import { assert, describe, it } from "@effect/vitest"
import { Effect } from "effect"
describe("normalize", () => {
it.effect.each([
{ input: " Ada ", expected: "ada" },
{ input: " Lin ", expected: "lin" },
{ input: " Nia ", expected: "nia" }
])("trims and lowercases case %#", ({ input, expected }) =>
Effect.gen(function*() {
assert.strictEqual(input.trim().toLowerCase(), expected)
}))
})

it.effect.prop generates inputs and runs the Effect body many times with different values, shrinking to a minimal counterexample on failure. Inputs are described with Schema, which doubles as an arbitrary generator.

import { assert, describe, it } from "@effect/vitest"
import { Effect, Schema } from "effect"
describe("string properties", () => {
// For each generated string, reversing it twice must be the identity.
it.effect.prop(
"reversing twice is identity",
[Schema.String],
([value]) =>
Effect.gen(function*() {
const reversedTwice = value.split("").reverse().reverse().join("")
assert.strictEqual(reversedTwice, value)
})
)
})

it.effect runs your test against the test services: a TestClock whose time only advances when you tell it to, and a TestConsole that captures output instead of printing it. This is what makes time-dependent tests deterministic (see TestClock).

Occasionally you genuinely want the real runtime — the system clock, real delays. Use it.live for those cases.

import { assert, describe, it } from "@effect/vitest"
import { Clock, Effect } from "effect"
describe("live runtime", () => {
// `it.live` uses the real Clock, so `Effect.sleep` actually waits.
it.live("sleeps against the real clock", () =>
Effect.gen(function*() {
const before = yield* Clock.currentTimeMillis
yield* Effect.sleep("10 millis")
const after = yield* Clock.currentTimeMillis
assert.isTrue(after - before >= 10)
}))
})

For tests that depend on something genuinely non-deterministic, wrap the body with it.flakyTest, which retries the Effect until it succeeds or a timeout elapses.

import { describe, it } from "@effect/vitest"
import { Effect, Random } from "effect"
describe("occasionally flaky", () => {
it.effect("eventually rolls a six", () =>
// Retry the Effect for up to 1 second until it succeeds.
it.flakyTest(
Effect.gen(function*() {
// `nextIntBetween` is inclusive on both bounds in v4, so this is 1-6.
const roll = yield* Random.nextIntBetween(1, 6)
// Fail unless we rolled a six — `flakyTest` will retry on failure.
yield* Effect.filterOrFail(
Effect.succeed(roll),
(n) => n === 6,
() => "not a six" as const
)
}),
"1 second"
))
})

Reach for flakyTest sparingly — most of the time the better fix is to make the test deterministic with a TestClock, a seeded Random, or a test layer rather than to retry it.