Testing
Testing database code requires careful consideration of isolation, speed, and reliability. Effect SQL's design makes testing straightforward.
Testing Strategies
1. In-Memory SQLite
The fastest approach—use an in-memory SQLite database:
typescript
import { it, describe } from "@effect/vitest"
import { SqliteClient } from "@effect/sql-sqlite-node"
import { SqlClient } from "@effect/sql"
const TestDatabase = SqliteClient.layer({
filename: ":memory:"
})
describe("UserRepository", () => {
it.effect("creates users", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
// Setup
yield* sql`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`
// Test
yield* sql`INSERT INTO users ${sql.insert({ name: "Alice" })}`
const users = yield* sql`SELECT * FROM users`
expect(users).toEqual([{ id: 1, name: "Alice" }])
}).pipe(Effect.provide(TestDatabase))
)
})Benefits:
- Very fast (no disk I/O)
- Perfect isolation (fresh database per test)
- No external dependencies
Limitations:
- SQLite syntax only
- May miss database-specific behavior
2. Test Containers
For testing against real databases, use test containers:
typescript
import { GenericContainer } from "testcontainers"
import { PgClient } from "@effect/sql-pg"
const makePostgresContainer = Effect.gen(function* () {
const container = yield* Effect.promise(() =>
new GenericContainer("postgres:15")
.withEnvironment({
POSTGRES_USER: "test",
POSTGRES_PASSWORD: "test",
POSTGRES_DB: "test"
})
.withExposedPorts(5432)
.start()
)
return PgClient.layer({
host: container.getHost(),
port: container.getMappedPort(5432),
database: "test",
username: "test",
password: Redacted.make("test")
})
})
describe("PostgreSQL Integration", () => {
it.effect("works with real PostgreSQL", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
const result = yield* sql`SELECT 1 + 1 as sum`
expect(result[0].sum).toBe(2)
}).pipe(
Effect.provide(makePostgresContainer)
),
{ timeout: 60000 }
)
})Benefits:
- Tests against real database
- Catches database-specific issues
- Production-like behavior
Limitations:
- Slower (container startup)
- Requires Docker
3. Shared Test Database
Use a shared test database with transaction rollback:
typescript
const TestDatabase = PgClient.layer({
host: "localhost",
database: "myapp_test"
})
const withTestTransaction = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
return yield* sql.withTransaction(
Effect.gen(function* () {
const result = yield* effect
// Always rollback - test isolation
yield* Effect.fail(new TestRollback())
return result
})
)
}).pipe(
Effect.catchTag("TestRollback", () => Effect.succeed(undefined as any))
)
describe("UserRepository", () => {
it.effect("creates users", () =>
withTestTransaction(
Effect.gen(function* () {
const repo = yield* UserRepository
const user = yield* repo.create({ name: "Alice" })
expect(user.name).toBe("Alice")
})
).pipe(Effect.provide(TestDatabase))
)
})Benefits:
- Fast (no database recreation)
- Tests against real database
- Transaction rollback provides isolation
Limitations:
- Some operations can't be rolled back
- Shared state risks if rollback fails
Testing Patterns
Repository Testing
typescript
// src/repositories/user.ts
export class UserRepository extends Effect.Service<UserRepository>()("UserRepository", {
effect: Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
return {
create: (data: { name: string }) =>
Effect.gen(function* () {
const [user] = yield* sql`
INSERT INTO users ${sql.insert(data)} RETURNING *
`
return user
}),
findById: (id: number) =>
Effect.gen(function* () {
const users = yield* sql`SELECT * FROM users WHERE id = ${id}`
return Option.fromNullable(users[0])
})
}
})
}) {}
// tests/repositories/user.test.ts
describe("UserRepository", () => {
const TestLayer = UserRepository.Default.pipe(
Layer.provideMerge(SqliteClient.layer({ filename: ":memory:" }))
)
it.effect("create returns the new user", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`
const repo = yield* UserRepository
const user = yield* repo.create({ name: "Alice" })
expect(user).toMatchObject({ name: "Alice" })
expect(user.id).toBeDefined()
}).pipe(Effect.provide(TestLayer))
)
it.effect("findById returns None for missing user", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`
const repo = yield* UserRepository
const result = yield* repo.findById(999)
expect(Option.isNone(result)).toBe(true)
}).pipe(Effect.provide(TestLayer))
)
})Migration Testing
typescript
describe("Migrations", () => {
it.effect("001_create_users creates users table", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
// Run migration
yield* migration001
// Verify table exists
const tables = yield* sql`
SELECT name FROM sqlite_master WHERE type='table' AND name='users'
`
expect(tables).toHaveLength(1)
// Verify columns
const cols = yield* sql`PRAGMA table_info(users)`
expect(cols.map(c => c.name)).toContain("id")
expect(cols.map(c => c.name)).toContain("name")
}).pipe(Effect.provide(TestDatabase))
)
})Transaction Testing
typescript
describe("Transactions", () => {
it.effect("rolls back on error", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)`
yield* sql`INSERT INTO accounts VALUES (1, 100)`
// Attempt transfer that fails
const result = yield* sql.withTransaction(
Effect.gen(function* () {
yield* sql`UPDATE accounts SET balance = balance - 50 WHERE id = 1`
yield* Effect.fail(new Error("Simulated failure"))
})
).pipe(Effect.either)
expect(Either.isLeft(result)).toBe(true)
// Balance should be unchanged
const [account] = yield* sql`SELECT balance FROM accounts WHERE id = 1`
expect(account.balance).toBe(100)
}).pipe(Effect.provide(TestDatabase))
)
})Data Loader Testing
typescript
describe("UserLoader", () => {
it.effect("batches multiple requests", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`
yield* sql`INSERT INTO users VALUES (1, 'Alice'), (2, 'Bob'), (3, 'Charlie')`
// Track queries
const queries: string[] = []
const trackedSql = /* wrap sql to track queries */
const loader = yield* makeUserLoader
// Concurrent requests should batch
const [u1, u2, u3] = yield* Effect.all([
loader.findById(1),
loader.findById(2),
loader.findById(3)
])
expect(queries).toHaveLength(1) // Only one batched query
expect(u1).toMatchObject({ name: "Alice" })
}).pipe(Effect.provide(TestLayer))
)
})Test Fixtures
Setup Helpers
typescript
const setupTestData = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)
`
yield* sql`
INSERT INTO users (name, email) VALUES
('Alice', 'alice@example.com'),
('Bob', 'bob@example.com')
`
})
describe("with test data", () => {
it.effect("finds existing users", () =>
Effect.gen(function* () {
yield* setupTestData
const repo = yield* UserRepository
const user = yield* repo.findByEmail("alice@example.com")
expect(Option.isSome(user)).toBe(true)
}).pipe(Effect.provide(TestLayer))
)
})Factory Functions
typescript
const makeUser = (overrides: Partial<User> = {}): User => ({
id: 1,
name: "Test User",
email: "test@example.com",
createdAt: new Date(),
...overrides
})
const insertUser = (user: Partial<User> = {}) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
const [inserted] = yield* sql`
INSERT INTO users ${sql.insert(makeUser(user))}
RETURNING *
`
return inserted
})Mocking
For unit testing without database:
typescript
const MockSqlClient = Layer.succeed(
SqlClient.SqlClient,
{
// Mock implementation
} as SqlClient.SqlClient
)
// Or mock at repository level
const MockUserRepository = Layer.succeed(
UserRepository,
{
create: () => Effect.succeed({ id: 1, name: "Mock User" }),
findById: () => Effect.succeed(Option.some({ id: 1, name: "Mock User" }))
}
)Best Practices
- Use fresh databases per test for isolation
- Keep tests fast with in-memory SQLite when possible
- Test against real databases in CI for integration tests
- Use factories for test data consistency
- Test error cases not just happy paths
- Run migrations in tests to catch migration issues early
Next Steps
- Migrations - Test your migrations
- Transactions - Test transaction behavior
- Error Handling - Test error scenarios