Testing Services
Real applications are built from services — a database
repository, an HTTP client, a clock. In tests you rarely want the real ones;
you want fast, in-memory implementations you can control and inspect. Because
Effect wires dependencies through Layer, swapping a service for a test version
is just providing a different layer. Nothing in the code under test changes.
The pattern below defines a TodoRepo service with a layerTest that stores
todos in a Ref. The test ref is itself a small service, so a test can reach in
and assert on the underlying state directly.
import { assert, describe, it, layer } from "@effect/vitest"import { Array, Context, Effect, Layer, Ref } from "effect"
interface Todo { readonly id: number readonly title: string}
// A tiny service that just holds the in-memory store. Exposing it as its own// service lets tests inspect the raw data when needed.class TodoRepoTestRef extends Context.Service<TodoRepoTestRef, Ref.Ref<Array<Todo>>>()( "app/TodoRepoTestRef") { static readonly layer = Layer.effect(TodoRepoTestRef, Ref.make(Array.empty<Todo>()))}
// The service under test. In production it would talk to a database; the test// layer below keeps everything in the Ref.class TodoRepo extends Context.Service<TodoRepo, { create(title: string): Effect.Effect<Todo> readonly list: Effect.Effect<ReadonlyArray<Todo>>}>()("app/TodoRepo") { static readonly layerTest = Layer.effect( TodoRepo, Effect.gen(function*() { const store = yield* TodoRepoTestRef
const create = Effect.fn("TodoRepo.create")(function*(title: string) { const todos = yield* Ref.get(store) const todo = { id: todos.length + 1, title } yield* Ref.set(store, [...todos, todo]) return todo })
const list = Ref.get(store)
return TodoRepo.of({ create, list }) }) // `provideMerge` supplies the test ref AND keeps it visible to tests, so a // test can yield* TodoRepoTestRef to inspect the store directly. ).pipe(Layer.provideMerge(TodoRepoTestRef.layer))}
describe("TodoRepo", () => { it.effect("starts empty and records created todos", () => Effect.gen(function*() { const repo = yield* TodoRepo assert.strictEqual((yield* repo.list).length, 0)
yield* repo.create("Write docs")
const todos = yield* repo.list assert.strictEqual(todos.length, 1) assert.strictEqual(todos[0].title, "Write docs") }).pipe(Effect.provide(TodoRepo.layerTest)))})Providing a test layer
Section titled “Providing a test layer”The test above ends with Effect.provide(TodoRepo.layerTest). That satisfies
the TodoRepo requirement on the test Effect with the in-memory layer. The
service contract (create, list) is identical to production — only the layer
differs — so the same tests would validate a real implementation if you swapped
in a live layer.
A common convention is to give each service a layerTest alongside its
production layer, both pointing at the same interface:
// static readonly layer = Layer.effect(TodoRepo, /* real DB impl */).pipe(// Layer.provide(Database.layer)// )//// static readonly layerTest = Layer.effect(TodoRepo, /* in-memory impl */).pipe(// Layer.provideMerge(TodoRepoTestRef.layer)// )Sharing a layer across a block with layer(...)
Section titled “Sharing a layer across a block with layer(...)”Providing the layer per test gives each test a fresh instance — a clean
store every time. Sometimes you want the opposite: one instance shared across a
group of tests, built once and torn down in afterAll. The layer(...) helper
from @effect/vitest does exactly that, and hands you an it already scoped to
that layer’s services.
import { assert, layer } from "@effect/vitest"import { Effect } from "effect"
// `TodoRepo.layerTest` is built once for the whole block.layer(TodoRepo.layerTest)("TodoRepo (shared)", (it) => { it.effect("creates the first todo", () => Effect.gen(function*() { const repo = yield* TodoRepo assert.strictEqual((yield* repo.list).length, 0) yield* repo.create("Write docs") assert.strictEqual((yield* repo.list).length, 1) }))
it.effect("sees state from the previous test", () => Effect.gen(function*() { const repo = yield* TodoRepo // The layer is shared, so the todo from the previous test is still here. assert.strictEqual((yield* repo.list).length, 1) yield* repo.create("Write docs again") assert.strictEqual((yield* repo.list).length, 2) }))})Inspecting state through a test ref
Section titled “Inspecting state through a test ref”Because the store is exposed as the TodoRepoTestRef service, a test can read
the raw Ref to make assertions the public interface does not surface. This is
useful for verifying side effects — that a write happened, a cache was
populated, an event was recorded — without adding test-only methods to the
production interface.
import { assert, describe, it } from "@effect/vitest"import { Effect, Ref } from "effect"
describe("inspecting the store", () => { it.effect("writes land in the underlying ref", () => Effect.gen(function*() { const repo = yield* TodoRepo const ref = yield* TodoRepoTestRef // the shared store, surfaced by provideMerge
yield* repo.create("Review docs")
// Assert against the raw data rather than only the public `list`. const todos = yield* Ref.get(ref) assert.strictEqual(todos.length, 1) assert.strictEqual(todos[0].title, "Review docs") }).pipe(Effect.provide(TodoRepo.layerTest)))})Layering services on top of each other
Section titled “Layering services on top of each other”Higher-level services depend on lower-level ones. A TodoService that depends
on TodoRepo gets a test layer by providing the repo’s test layer to it.
Using Layer.provideMerge keeps the repo (and, through it, the test ref)
reachable from tests as well, so you can assert at any level of the stack.
class TodoService extends Context.Service<TodoService, { addAndCount(title: string): Effect.Effect<number>}>()("app/TodoService") { static readonly layerTest = Layer.effect( TodoService, Effect.gen(function*() { const repo = yield* TodoRepo
const addAndCount = Effect.fn("TodoService.addAndCount")(function*(title: string) { yield* repo.create(title) const todos = yield* repo.list return todos.length })
return TodoService.of({ addAndCount }) }) // Provide the repo's test layer as the dependency, and keep it (plus the // test ref) merged into the context so tests can reach every level. ).pipe(Layer.provideMerge(TodoRepo.layerTest))}
describe("TodoService", () => { it.effect("delegates to the repo and counts", () => Effect.gen(function*() { const service = yield* TodoService const count = yield* service.addAndCount("Review docs") assert.strictEqual(count, 1)
// Still able to inspect the lowest-level store, thanks to provideMerge. const ref = yield* TodoRepoTestRef assert.strictEqual((yield* Ref.get(ref)).length, 1) }).pipe(Effect.provide(TodoService.layerTest)))})When the service under test depends on time, combine these test layers with the
TestClock — under it.effect it is already provided, so
sleeps and schedules inside your services stay deterministic too.