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 two layers that share one
interface: a production layer backed by a Database, and a layerTest that
stores todos in a Ref. The code under test only ever sees the TodoRepo
interface, so swapping one layer for the other changes nothing about it. 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}
// An external dependency the *production* repo talks to. The real layer opens a// connection pool — elided here, since the focus is the test layer. Note this is// the only place a database appears: the test layer never touches it.class Database extends Context.Service<Database, { query<A>(sql: string, params: ReadonlyArray<unknown>): Effect.Effect<ReadonlyArray<A>>}>()("app/Database") { static readonly layer = Layer.succeed(Database, { query: () => Effect.die("not implemented in this example") })}
// A tiny service that just holds the in-memory store used by the *test* layer.// 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. The interface is the contract; everything below is two// implementations of it that the tests can use interchangeably.class TodoRepo extends Context.Service<TodoRepo, { create(title: string): Effect.Effect<Todo> readonly list: Effect.Effect<ReadonlyArray<Todo>>}>()("app/TodoRepo") { // Production: SQL against the real Database. `Layer.provide` consumes the // Database dependency and *hides* it — the resulting layer provides TodoRepo // and nothing else, because the database is an internal implementation detail. static readonly layer = Layer.effect( TodoRepo, Effect.gen(function*() { const db = yield* Database
const create = Effect.fn("TodoRepo.create")(function*(title: string) { const rows = yield* db.query<Todo>( "INSERT INTO todos (title) VALUES ($1) RETURNING id, title", [title] ) return rows[0] })
const list = db.query<Todo>("SELECT id, title FROM todos ORDER BY id", [])
return TodoRepo.of({ create, list }) }) ).pipe(Layer.provide(Database.layer))
// Test: the *same* interface, no database — state lives in the Ref. The bodies // differ, the contract does not. `provideMerge` supplies the test ref AND keeps // it visible, so a test can yield* TodoRepoTestRef to inspect the store directly. 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 }) }) ).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), satisfying the
TodoRepo requirement on the test Effect with the in-memory layer. Because the
service contract (create, list) is identical to production, the exact same
test body would validate the real implementation if you provided TodoRepo.layer
instead — only the layer changes, never the test. Giving each service a layer
and a layerTest side by side, both pointing at one interface, is the convention
this page follows.
Layer.provide vs Layer.provideMerge
Section titled “Layer.provide vs Layer.provideMerge”The two layers above each wire in a dependency, but they use different combinators. The choice is about visibility — whether the dependency stays reachable in the layer’s output.
Layer.provide(dep)feedsdepinto the layer being built and then hides it. The productionTodoRepo.layeruses it forDatabase: the database is an internal implementation detail, so the resulting layer providesTodoRepoand nothing else. Code that provides this layer can reachTodoRepobut notDatabase.Layer.provideMerge(dep)feedsdepin and keeps it in the output. The test layer uses it forTodoRepoTestRef: the resulting layer provides bothTodoRepoandTodoRepoTestRef, so a test canyield* TodoRepoTestRefto inspect the raw store (see Inspecting state through a test ref below).
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.
The approach here fakes each service in turn. The opposite strategy — keep every service real and swap only the lowest-level dependency (a database, an HTTP client) — is covered in Integration testing.