Skip to content

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)))
})

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.

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) feeds dep into the layer being built and then hides it. The production TodoRepo.layer uses it for Database: the database is an internal implementation detail, so the resulting layer provides TodoRepo and nothing else. Code that provides this layer can reach TodoRepo but not Database.
  • Layer.provideMerge(dep) feeds dep in and keeps it in the output. The test layer uses it for TodoRepoTestRef: the resulting layer provides both TodoRepo and TodoRepoTestRef, so a test can yield* TodoRepoTestRef to 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)
}))
})

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)))
})

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.