05·02Test Layers

A `Tag` is an interface, a `Layer` is one implementation of that interface. Tests swap the production `Layer` for one built from stubs and spies, the business logic compiles unchanged.

29 - Test Layers

Previous · Next

A Tag is an interface, a Layer is one implementation of that interface. Tests swap the production Layer for one built from stubs and spies, the business logic compiles unchanged.

There is no module patching, no global state, no teardown. This step covers Layer.succeed, Layer.sync, and Layer.merge as the building blocks of a test layer.

What you’ll work through

index.test.ts contains three interactive beats — layer-succeed, layer-sync, and layer-merge — plus a full sender-and-spy example that already passes. Each stubbed beat builds one piece of a test layer: a stateless stub, a per-scope counter, and the merge that hands both to Effect.provide.

By the end you should be able to look at a function with two service dependencies and write its test layer without consulting the docs: pick Layer.succeed for stateless stubs, Layer.sync for per-scope state, and Layer.merge to combine them.


A Layer Is an Implementation

The function under test asks for EmailService | UserRepository. Production builds a Layer that provides real implementations; the test builds one that provides stubs.

The function is identical in both worlds.

production: logic(...) ──► provide(Live) ──► runPromise ──► result
│
├── EmailServiceLive
└── UserRepositoryLive
tests: logic(...) ──► provide(Test) ──► runPromise ──► result
│
├── EmailServiceStub
└── UserRepositoryStub

Layers are values: they merge, they compose, they can be partially overridden. The compiler verifies that every requirement is satisfied before the effect can run.


Layer.succeed

Creates a Layer that provides a service from a pre-built value. The implementation must satisfy the Tag’s interface, TypeScript checks the shape at compile time.

import { Context, Effect, Layer } from "effect"
class EmailService extends Context.Service<EmailService, {
readonly send: (to: string, body: string) => Effect.Effect<void>
}>()("EmailService") {}
// ┌─── Layer<EmailService, never, never>
// ▼
const EmailTest = Layer.succeed(EmailService, {
send: (_to, _body) => Effect.void,
})

When to Use

When the stub is a plain object with no internal state, or when state is held in an enclosing closure that the test scope manages directly (e.g., a const sent: Array<...> = [] declared in the test body).

Your turn build `Layer.succeed(EmailService, { send: ... })` whose `send` pushes `{ to, body }` into the test's `sent` array.


Layer.sync

Creates a Layer whose implementation is built lazily by a thunk. The thunk runs once per Layer scope, so each provided scope gets a fresh instance.

import { Context, Effect, Layer } from "effect"
class Counter extends Context.Service<Counter, { readonly tick: Effect.Effect<number> }>()("Counter") {}
// ┌─── Layer<Counter, never, never>
// ▼
const CounterTest = Layer.sync(Counter, () => {
let n = 0
return { tick: Effect.sync(() => ++n) }
})

Details

Layer.succeed captures the value once at definition time, every test that uses the same Layer shares the same object. Layer.sync re-runs its builder, giving each scope its own state.

For counters, in-memory stores, or anything that holds mutable bookkeeping, prefer Layer.sync.

When to Use

When the stub needs per-test mutable state and you want isolation without manually resetting in beforeEach.

> once and shared across every test using that layer. Move it into the > `Layer.sync` builder so each provided scope gets a fresh closure.

Your turn build `Layer.sync(Counter, () => { let n = 0; ... })` whose closure increments `n` on each `tick`.


Layer.merge

Combines two independent Layers into a single Layer that provides both services. The result is what Effect.provide consumes.

import { Layer } from "effect"
// ┌─── Layer<EmailService | UserRepository, never, never>
// ▼
const TestLayer = Layer.merge(EmailTest, UserRepoTest)

Details

Effect.provide(effect, layer) requires the layer to provide everything the effect requires. If the effect needs two services and the layer provides only one, the R channel still shows the missing service, a type error, not a runtime surprise.

Your turn call `Layer.merge(UserRepoTest, EmailCapture)` inside `Effect.provide` so both services are satisfied in one pass.


Write a test Layer from scratch

A test Layer is just Layer.succeed (or Layer.sync) with an in-memory stub — no framework, no magic, no special test-only API. Writing one from scratch shows the whole pattern: create a mutable capture array in the factory closure, return an object that reads or writes it, and expose any inspection method the test needs.

import { Effect, Layer } from "effect"
const EmailCapture = Layer.sync(EmailService, () => {
const sent: Array<{ to: string; subject: string }> = []
return {
send: (to, subject) =>
Effect.sync(() => { sent.push({ to, subject }) }),
// Test-only accessor — the production type doesn't need this.
inbox: () => Effect.succeed(sent),
}
})

The closure isolates state to a single Layer construction — each Layer.sync builder runs once per scope, so two tests sharing the same Layer value get two separate inboxes. If you want one shared inbox across tests, lift the array outside the factory; if you want isolation, leave it inside.

Your turn write a `UserRepoTest` Layer from scratch — closure-captured array, `findById` method, and a test-only `seen()` accessor that returns the list of ids requested.


A Full Example

A function that depends on two services. The test builds a stub for each, captures the arguments passed to the email spy, and asserts on the recorded call.

import { Context, Effect, Layer } from "effect"
class EmailError {
readonly _tag = "EmailError"
constructor(readonly reason: string) {}
}
class EmailService extends Context.Service<EmailService, {
readonly send: (to: string, body: string) => Effect.Effect<void, EmailError>
}>()("EmailService") {}
class UserRepository extends Context.Service<UserRepository, {
readonly getById: (
id: string
) => Effect.Effect<{ id: string; email: string } | null>
}>()("UserRepository") {}
// ┌─── Effect<string, EmailError, EmailService | UserRepository>
// ▼
const sendWelcomeEmail = (userId: string) =>
Effect.gen(function* () {
const users = yield* UserRepository
const email = yield* EmailService
const user = yield* users.getById(userId)
if (!user) {
return yield* Effect.fail(new EmailError("User not found"))
}
yield* email.send(user.email, "Welcome!")
return `Sent welcome to ${user.email}`
})
// In a test:
const sent: Array<{ to: string; body: string }> = []
const UserRepoTest = Layer.succeed(UserRepository, {
getById: (id) =>
id === "u1"
? Effect.succeed({ id: "u1", email: "alice@test.com" })
: Effect.succeed(null),
})
const EmailSpy = Layer.succeed(EmailService, {
send: (to, body) => Effect.sync(() => void sent.push({ to, body })),
})
const result = await Effect.runPromise(
sendWelcomeEmail("u1").pipe(Effect.provide(Layer.merge(UserRepoTest, EmailSpy)))
)
// "Sent welcome to alice@test.com"
// sent === [{ to: "alice@test.com", body: "Welcome!" }]

The error path is the same shape: replace EmailSpy with a layer whose send returns Effect.fail(...), then assert on the resulting Exit.


Common Pitfalls

1, Providing one service when the effect needs two. The R channel will still show the missing dependency. Layer.merge (or piping a second Effect.provide) is required.

2, Sharing state through Layer.succeed. A captured let count = 0 inside the implementation is shared across every test using that layer. Move the state into Layer.sync’s builder so each scope gets a fresh closure.

3, Forgetting to run the effect. Building an Effect.gen is not the same as executing it. Wrap the outermost effect in Effect.runPromise or Effect.runPromiseExit for the runner to do anything.

4, Casting the implementation. Layer.succeed(Tag, value as any) defeats the point. The compile-time check that the implementation matches the interface is the safety net that replaces mock cleanup.


the test's `sent` array. - [ ] In the `layer-sync` beat, build a `Counter` layer whose builder closes over a fresh `n` per scope. - [ ] In the `layer-merge` beat, combine the two stub Layers with `Layer.merge` before `Effect.provide`. - [ ] Read the full sender-and-spy example — note how the same `Layer.merge` pattern wires two services into a single `provide` call.

See Also

  • Previous, 28-testing-basics: the runners and Exit guards used in every test.
  • Prerequisite, 22-layers: layer composition fundamentals.
  • Prerequisite, 23-service-driven: defining Tags and service interfaces.
  • Next, 30-test-clock: deterministic time inside the same layer pattern.
  • Later, 32-retry-and-timeout: resilient effects, still service-driven.

Run the tests: pnpm test 29-test-layers


The full test suite

The index.test.ts for this lesson, verbatim from steps/29-test-layers/.

index.test.ts
import { describe, it, expect } from "vite-plus/test"
import { Context, Data, Effect, Exit, Layer } from "effect"
// =============================================================================
// 16 - Test Layers
// =============================================================================
//
// A Tag is an interface; a Layer is one implementation of it. Tests provide a
// Layer built from stubs and spies, then run the production function unchanged.
//
// Layer.succeed — provides a service from a pre-built value.
// Layer.sync — builds the service lazily; one fresh instance per Layer scope.
// Layer.merge — combines two Layers into one that provides both services.
class EmailError extends Data.TaggedError("EmailError")<{
readonly reason: string
}> {}
class EmailService extends Context.Service<EmailService, {
readonly send: (to: string, body: string) => Effect.Effect<void, EmailError>
}>()("EmailService") {}
class UserRepository extends Context.Service<UserRepository, {
readonly getById: (
id: string
) => Effect.Effect<{ id: string; email: string } | null>
}>()("UserRepository") {}
// ┌─── Effect<string, EmailError, EmailService | UserRepository>
// ▼
const sendWelcomeEmail = (userId: string) =>
Effect.gen(function* () {
const users = yield* UserRepository
const email = yield* EmailService
const user = yield* users.getById(userId)
if (!user) {
return yield* Effect.fail(new EmailError({ reason: "User not found" }))
}
yield* email.send(user.email, "Welcome!")
return `Sent welcome to ${user.email}`
})
describe("16 - Test Layers", () => {
// @beat
// id: layer-succeed
// shape: one-todo
// title: Stub a service with Layer.succeed
// teaches: Layer.succeed provides an implementation from a pre-built value that satisfies the Tag.
// hint: Layer.succeed(EmailService, { send: ... })
describe("Layer.succeed", () => {
it("provides a stub implementation that satisfies the Tag", async () => {
const sent: Array<{ to: string; body: string }> = []
const UserRepoTest = Layer.succeed(UserRepository, {
getById: (id) =>
id === "u1"
? Effect.succeed({ id: "u1", email: "alice@test.com" })
: Effect.succeed(null),
})
const EmailTest = /* @stub */ Layer.succeed(EmailService, {
send: (to, body) =>
Effect.sync(() => {
sent.push({ to, body })
}),
}) /* @end */
const result = await Effect.runPromise(
sendWelcomeEmail("u1").pipe(
Effect.provide(Layer.merge(UserRepoTest, EmailTest))
)
)
expect(result).toBe("Sent welcome to alice@test.com")
expect(sent).toEqual([{ to: "alice@test.com", body: "Welcome!" }])
})
it("surfaces a stub's typed failure through Exit", async () => {
const UserRepoTest = Layer.succeed(UserRepository, {
getById: () => Effect.succeed({ id: "u1", email: "alice@test.com" }),
})
const EmailBroken = Layer.succeed(EmailService, {
send: () => Effect.fail(new EmailError({ reason: "SMTP down" })),
})
const exit = await Effect.runPromiseExit(
sendWelcomeEmail("u1").pipe(
Effect.provide(Layer.merge(UserRepoTest, EmailBroken))
)
)
if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") {
expect(exit.cause.reasons[0].error._tag).toBe("EmailError")
expect(exit.cause.reasons[0].error.reason).toBe("SMTP down")
} else {
throw new Error("expected a Fail cause")
}
})
it("short-circuits before the second service when the first reports null", async () => {
let sendCalls = 0
const UserRepoTest = Layer.succeed(UserRepository, {
getById: () => Effect.succeed(null),
})
const EmailSpy = Layer.succeed(EmailService, {
send: () =>
Effect.sync(() => {
sendCalls += 1
}),
})
const exit = await Effect.runPromiseExit(
sendWelcomeEmail("missing").pipe(
Effect.provide(Layer.merge(UserRepoTest, EmailSpy))
)
)
expect(Exit.isFailure(exit)).toBe(true)
expect(sendCalls).toBe(0)
})
})
// @end-beat
// @beat
// id: layer-sync
// shape: one-todo
// title: Build per-scope state with Layer.sync
// teaches: Layer.sync runs its builder once per scope, giving each test its own closure for mutable state.
// hint: Layer.sync(Counter, () => { let n = 0; return { tick: ... } })
describe("Layer.sync", () => {
class Counter extends Context.Service<Counter, { readonly tick: Effect.Effect<number> }>()("Counter") {}
it("builds a fresh instance per Layer scope", async () => {
// ┌─── Layer<Counter, never, never>
// ▼
const CounterTest = /* @stub */ Layer.sync(Counter, () => {
let n = 0
return { tick: Effect.sync(() => ++n) }
}) /* @end */
const result = await Effect.runPromise(
Effect.gen(function* () {
const counter = yield* Counter
const a = yield* counter.tick
const b = yield* counter.tick
return [a, b] as const
}).pipe(Effect.provide(CounterTest))
)
expect(result).toEqual([1, 2])
})
})
// @end-beat
// @beat
// id: layer-merge
// shape: one-todo
// title: Combine fixtures with Layer.merge
// teaches: Layer.merge combines two Layers into one that provides both services for Effect.provide.
// hint: Layer.merge(UserRepoTest, EmailCapture)
describe("Layer.merge", () => {
it("combines two Layers into one that satisfies both requirements", async () => {
const calls: Array<{ to: string; body: string }> = []
const UserRepoTest = Layer.succeed(UserRepository, {
getById: () => Effect.succeed({ id: "u1", email: "carol@test.com" }),
})
const EmailCapture = Layer.succeed(EmailService, {
send: (to, body) =>
Effect.sync(() => {
calls.push({ to, body })
}),
})
await Effect.runPromise(
sendWelcomeEmail("u1").pipe(
Effect.provide(/* @stub */ Layer.merge(UserRepoTest, EmailCapture) /* @end */)
)
)
expect(calls).toEqual([{ to: "carol@test.com", body: "Welcome!" }])
})
})
// @end-beat
// @beat
// id: write-the-test-layer
// shape: from-scratch
// title: Write both test layers from scratch
// teaches: A test layer is just Layer.succeed with an in-memory stub — no framework, no magic. Writing one shows the pattern: create a mutable capture array in the closure, return an object that reads or writes it.
// hint: Layer.succeed(Tag, { method: (args) => Effect.sync(() => { captured.push(args) }) })
describe("capstone: write both layers", () => {
it("implements UserRepository and EmailService layers from scratch", async () => {
const delivered: Array<{ to: string; body: string }> = []
// Your turn: write both layers so sendWelcomeEmail("u99") delivers a welcome email.
// UserRepository.getById("u99") must return { id: "u99", email: "zara@test.com" }
// EmailService.send(to, body) must push { to, body } into `delivered`
const UserRepoTest = /* @stub */ Layer.succeed(UserRepository, {
getById: (id) => Effect.succeed({ id, email: "zara@test.com" }),
}) /* @end */
const EmailTest = /* @stub */ Layer.succeed(EmailService, {
send: (to, body) =>
Effect.sync(() => {
delivered.push({ to, body })
}),
}) /* @end */
await Effect.runPromise(
sendWelcomeEmail("u99").pipe(
Effect.provide(Layer.merge(UserRepoTest, EmailTest)),
),
)
expect(delivered).toEqual([{ to: "zara@test.com", body: "Welcome!" }])
})
})
// @end-beat
})
← PreviousTesting BasicsNext →TestClock