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

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

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.