Skip to content

Integration Testing

Testing services covers the unit style: give each service a fake layerTest and test it in isolation. Integration testing is the opposite end of the spectrum — you run the real services, wired together exactly as in production, and swap out only the one piece you can’t (or don’t want to) run for real: usually the lowest-level effectful dependency, like the database.

The classic case is “test the whole stack against an in-memory database instead of production Postgres.” The good news is that Effect makes this a one-layer change, if you structure the graph so the database is a swappable seam rather than something baked in.

A service layer that bakes in its dependency with Layer.provide seals that dependency — nothing downstream can replace it:

// Sealed: the Postgres client is baked in. There is no seam to swap.
static readonly layer = UserRepo.layerNoDeps.pipe(Layer.provide(PostgresClient))

A layer that leaves the dependency in its requirements (R) keeps the seam open — a caller decides what fills it:

// Open: the layer produces UserRepo but still REQUIRES a SqlClient. The third
// type parameter is the seam.
static readonly layerNoDeps: Layer.Layer<UserRepo, never, SqlClient.SqlClient> = /* ... */

So the recipe for integration-testable code is: define each service with a dependency-open layer, compose those, and provide the bottom dependency exactly once — at the edge. Production fills the seam with the real thing; tests fill it with a fast one. (This is the same layerNoDeps convention from Composing Layers.)

Two real services: a UserRepo that runs SQL, and a UserService on top of it. Neither provides a database — both leave SqlClient open.

import { Context, Effect, Layer } from "effect"
import { SqlClient, SqlError } from "effect/unstable/sql"
interface User {
readonly id: number
readonly name: string
}
// Talks real SQL. Its layer REQUIRES a SqlClient but does not provide one —
// that open requirement is the seam we swap.
class UserRepo extends Context.Service<UserRepo, {
create(name: string): Effect.Effect<User, SqlError.SqlError>
readonly list: Effect.Effect<ReadonlyArray<User>, SqlError.SqlError>
}>()("app/UserRepo") {
static readonly layerNoDeps = Layer.effect(
UserRepo,
Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
const create = Effect.fn("UserRepo.create")(function*(name: string) {
const rows = yield* sql<User>`
INSERT INTO users (name) VALUES (${name}) RETURNING id, name
`
return rows[0]
})
const list = sql<User>`SELECT id, name FROM users ORDER BY id`
return UserRepo.of({ create, list })
})
)
}
// Higher-level logic. Depends on UserRepo (which transitively needs SqlClient),
// and likewise leaves both open.
class UserService extends Context.Service<UserService, {
register(name: string): Effect.Effect<User, SqlError.SqlError>
readonly list: Effect.Effect<ReadonlyArray<User>, SqlError.SqlError>
}>()("app/UserService") {
static readonly layerNoDeps = Layer.effect(
UserService,
Effect.gen(function*() {
const repo = yield* UserRepo
const register = Effect.fn("UserService.register")(function*(name: string) {
return yield* repo.create(name.trim())
})
return UserService.of({ register, list: repo.list })
})
)
}
// The whole business-logic graph, wired together but with the database still
// OPEN. The one remaining requirement is exactly `SqlClient.SqlClient` — the
// single seam every integration test fills.
const AppServices: Layer.Layer<UserService, never, SqlClient.SqlClient> =
UserService.layerNoDeps.pipe(Layer.provide(UserRepo.layerNoDeps))

Note what AppServices is: the real UserService and the real UserRepo, composed, with SqlClient.SqlClient left in the requirements. The wiring is final — only the database is missing.

The swap works because different SQL clients provide the same service tag. Both PgClient.layer and SqliteClient.layer output SqlClient.SqlClient, so a repository that depends on SqlClient.SqlClient doesn’t know or care which one is underneath. Each database layer here also runs its schema migration at build time (dialects differ, so the DDL does too — more on that below).

import { Config, Effect, Layer } from "effect"
import { SqlClient } from "effect/unstable/sql"
import { PgClient } from "@effect/sql-pg"
import { SqliteClient } from "@effect/sql-sqlite-node"
// Production: real Postgres from config. `Layer.effectDiscard` runs the schema
// DDL when the layer builds; `provideMerge` builds the client first, feeds it to
// the migration, and re-exposes SqlClient in the output.
const PostgresDatabase = Layer.effectDiscard(
Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
)`
})
).pipe(
Layer.provideMerge(PgClient.layerConfig({ url: Config.redacted("DATABASE_URL") }))
)
// Tests: an in-memory SQLite database. `filename: ":memory:"` lives only in RAM,
// so it needs no files and vanishes when the layer is torn down. Same
// `SqlClient.SqlClient` tag — so it satisfies the exact same requirement.
const InMemoryDatabase = Layer.effectDiscard(
Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)`
})
).pipe(
Layer.provideMerge(SqliteClient.layer({ filename: ":memory:" }))
)

Production and test wiring now differ by exactly one layer — the database. Every service above is untouched.

import { Layer } from "effect"
// Production: real services, real Postgres.
const AppLayer = AppServices.pipe(Layer.provide(PostgresDatabase))
// Tests: the SAME real services, backed by in-memory SQLite.
const TestAppLayer = AppServices.pipe(Layer.provide(InMemoryDatabase))

The integration test then runs UserService for real — its logic, the repo’s SQL, the migration, transactions — and asserts through the public interface:

import { assert, layer } from "@effect/vitest"
import { Effect } from "effect"
layer(TestAppLayer)("UserService (integration)", (it) => {
it.effect("registers and lists users through the real repo and SQL", () =>
Effect.gen(function*() {
const users = yield* UserService
yield* users.register(" Ada ") // the real service trims the name
yield* users.register("Grace")
const all = yield* users.list
// Proves the whole stack ran: trimming (service), INSERT/SELECT (repo),
// ordering and persistence (SQLite). No mocks anywhere.
assert.deepStrictEqual(all.map((u) => u.name), ["Ada", "Grace"])
}))
})

If a test needs to reach the database directly — to seed fixtures or assert on raw rows — provide the database with provideMerge instead of provide so SqlClient.SqlClient stays in the context:

const TestAppLayerWithSql = AppServices.pipe(Layer.provideMerge(InMemoryDatabase))
layer(TestAppLayerWithSql)("UserService (with raw SQL access)", (it) => {
it.effect("seeds a row directly, then reads it through the service", () =>
Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
yield* sql`INSERT INTO users (name) VALUES ('Seeded')`
const users = yield* UserService
const all = yield* users.list
assert.strictEqual(all[0].name, "Seeded")
}))
})

When you need true fidelity, swap the leaf for a real Postgres instead — the mechanism is identical, only the database layer changes. A common approach boots a throwaway Postgres in a container and points PgClient at it. Because starting the container is itself effectful, build the layer with Layer.unwrap:

import { Effect, Layer, Redacted, Scope } from "effect"
import { PgClient } from "@effect/sql-pg"
// Your helper: start a container, return its URL, tear it down with the scope.
declare const startPostgresContainer: Effect.Effect<{ readonly url: string }, never, Scope.Scope>
// A real Postgres, started fresh and torn down with the scope. Still provides
// SqlClient.SqlClient — so it drops into `AppServices` exactly like the others.
const ContainerDatabase = Layer.unwrap(
Effect.gen(function*() {
const container = yield* startPostgresContainer
return PgClient.layer({ url: Redacted.make(container.url) })
})
)
const ContainerAppLayer = AppServices.pipe(Layer.provide(ContainerDatabase))

A useful pattern is to run the same integration suite against both: in-memory SQLite for the fast inner loop, a real Postgres container in CI.

Nothing here is database-specific. The seam is just “a low-level service left open in the graph,” and the swap is “provide a different layer for that tag.” The same shape works for:

  • An HTTP client — run your services for real but provide a stub HttpClient (via Layer.succeed, or Layer.mock for a partial implementation) that returns canned responses, so tests don’t hit the network.
  • A clockit.effect already provides a deterministic TestClock, so any sleeps, timeouts, and schedules deep in your services advance instantly under test.
  • Config — point a real config-driven layer at test values rather than production secrets.

The discipline is the same in every case: keep the thing you want to replace as an open requirement, compose everything else for real, and fill the seam once at the edge — with the real implementation in production and a controllable one in tests.