Testing Setup
Install the testing utilities:pnpm add -D @effect/vitest vitest
vitest.config.ts
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
globals: false,
include: ["tests/**/*.test.ts"]
}
})
Basic Testing
Useit.effect for testing Effect programs.
import { it } from "@effect/vitest"
import { Effect } from "effect"
import { assert } from "@effect/vitest/utils"
it.effect("should add two numbers", () =>
Effect.gen(function* () {
const result = yield* Effect.succeed(2 + 2)
assert.strictEqual(result, 4)
})
)
Never use
Effect.runSync or Effect.runPromise inside tests. Always use it.effect which handles the runtime automatically.Testing Services
Creating Test Layers
Create mock implementations for testing.tests/helpers/UserService.test.ts
import { Layer, Effect, Ref } from "effect"
import { UserService } from "../../src/services/UserService.js"
import type { User } from "../../src/domain/User.js"
export const makeTestUserService = Effect.gen(function* () {
const users = yield* Ref.make<Map<string, User>>(new Map())
return UserService.of({
findById: (id) =>
Ref.get(users).pipe(
Effect.flatMap(map =>
map.has(id)
? Effect.succeed(map.get(id)!)
: Effect.fail(new UserNotFound(id))
)
),
create: (email, name) =>
Effect.gen(function* () {
const id = Math.random().toString()
const user = { id, email, name, createdAt: new Date() }
yield* Ref.update(users, map => new Map(map).set(id, user))
return user
}),
list: () =>
Ref.get(users).pipe(
Effect.map(map => Array.from(map.values()))
)
})
})
export const UserServiceTest = Layer.effect(
UserService,
makeTestUserService
)
Use
Ref for stateful test services. This allows you to verify state changes during tests.Testing with Dependencies
tests/unit/OrderService.test.ts
import { it } from "@effect/vitest"
import { Effect, Layer } from "effect"
import { OrderService } from "../../src/services/OrderService.js"
import { UserServiceTest } from "../helpers/UserService.test.js"
import { PaymentServiceTest } from "../helpers/PaymentService.test.js"
const TestLayer = Layer.mergeAll(
UserServiceTest,
PaymentServiceTest
)
it.effect("should create order", () =>
Effect.gen(function* () {
const orderService = yield* OrderService
const userService = yield* UserService
const user = yield* userService.create("[email protected]", "Test User")
const order = yield* orderService.create(user.id, [{ id: "1", quantity: 2 }])
assert.strictEqual(order.userId, user.id)
assert.strictEqual(order.items.length, 1)
}).pipe(
Effect.provide(TestLayer)
)
)
Testing Error Scenarios
Testing Failures
import { it } from "@effect/vitest"
import { Effect, Exit } from "effect"
import { assert } from "@effect/vitest/utils"
it.effect("should fail with UserNotFound", () =>
Effect.gen(function* () {
const service = yield* UserService
const exit = yield* Effect.exit(service.findById("nonexistent"))
assert.true(Exit.isFailure(exit))
if (Exit.isFailure(exit)) {
const error = exit.cause.failures[0]
assert.strictEqual(error._tag, "UserNotFound")
assert.strictEqual(error.id, "nonexistent")
}
}).pipe(
Effect.provide(UserServiceTest)
)
)
Testing Error Recovery
it.effect("should recover from errors", () =>
Effect.gen(function* () {
const service = yield* UserService
const result = yield* service.findById("invalid").pipe(
Effect.catchTag("UserNotFound", () =>
Effect.succeed({ id: "default", name: "Guest", email: "[email protected]" })
)
)
assert.strictEqual(result.name, "Guest")
}).pipe(
Effect.provide(UserServiceTest)
)
)
Testing Concurrent Operations
import { it } from "@effect/vitest"
import { Effect, Fiber, Ref } from "effect"
it.effect("should handle concurrent updates", () =>
Effect.gen(function* () {
const counter = yield* Ref.make(0)
const increment = Ref.update(counter, n => n + 1)
yield* Effect.all(
Array.from({ length: 100 }, () => increment),
{ concurrency: "unbounded" }
)
const result = yield* Ref.get(counter)
assert.strictEqual(result, 100)
})
)
Testing with TestClock
Control time in tests for scheduling and timeouts.import { it } from "@effect/vitest"
import { Effect, TestClock, Duration, Fiber } from "effect"
it.effect("should delay execution", () =>
Effect.gen(function* () {
const start = Date.now()
const fiber = yield* Effect.sleep(Duration.seconds(10)).pipe(
Effect.flatMap(() => Effect.succeed("done")),
Effect.fork
)
yield* TestClock.adjust(Duration.seconds(10))
const result = yield* Fiber.join(fiber)
assert.strictEqual(result, "done")
})
)
it.effect("should timeout correctly", () =>
Effect.gen(function* () {
const fiber = yield* Effect.never.pipe(
Effect.timeout(Duration.seconds(5)),
Effect.fork
)
yield* TestClock.adjust(Duration.seconds(5))
const result = yield* Fiber.join(fiber)
assert.true(Option.isNone(result))
})
)
Use
TestClock.adjust to advance virtual time without actually waiting. This makes time-dependent tests fast and deterministic.Testing Streams
import { it } from "@effect/vitest"
import { Stream, Effect, Chunk } from "effect"
import { assert } from "@effect/vitest/utils"
it.effect("should emit all values", () =>
Effect.gen(function* () {
const stream = Stream.make(1, 2, 3, 4, 5)
const result = yield* Stream.runCollect(stream)
assert.deepStrictEqual(
Array.from(result),
[1, 2, 3, 4, 5]
)
})
)
it.effect("should filter stream", () =>
Effect.gen(function* () {
const stream = Stream.range(1, 10).pipe(
Stream.filter(n => n % 2 === 0)
)
const result = yield* Stream.runCollect(stream)
assert.deepStrictEqual(
Array.from(result),
[2, 4, 6, 8]
)
})
)
Property-Based Testing
Use FastCheck for property-based testing.import { it } from "@effect/vitest"
import { Effect, fc } from "effect"
it.effect("addition is commutative", () =>
Effect.gen(function* () {
yield* fc.assert(
fc.asyncProperty(
fc.integer(),
fc.integer(),
async (a, b) => {
const result1 = await Effect.runPromise(Effect.succeed(a + b))
const result2 = await Effect.runPromise(Effect.succeed(b + a))
return result1 === result2
}
)
)
})
)
Testing Retries
it.effect("should retry on failure", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const task = Ref.updateAndGet(attempts, n => n + 1).pipe(
Effect.flatMap(n =>
n < 3
? Effect.fail(new Error("Not yet"))
: Effect.succeed("Success")
)
)
const result = yield* task.pipe(
Effect.retry(Schedule.recurs(5))
)
const finalAttempts = yield* Ref.get(attempts)
assert.strictEqual(result, "Success")
assert.strictEqual(finalAttempts, 3)
})
)
Testing Interruption
import { it } from "@effect/vitest"
import { Effect, Fiber, Ref, Duration } from "effect"
it.effect("should handle interruption", () =>
Effect.gen(function* () {
const released = yield* Ref.make(false)
const task = Effect.acquireRelease(
Effect.succeed("resource"),
() => Ref.set(released, true)
).pipe(
Effect.flatMap(() => Effect.never)
)
const fiber = yield* Effect.fork(task)
yield* Fiber.interrupt(fiber)
const wasReleased = yield* Ref.get(released)
assert.true(wasReleased)
})
)
Always test that resources are properly released, especially when interruption can occur.
Fixture Data
Create reusable test fixtures.tests/helpers/fixtures.ts
import type { User, Order } from "../../src/domain/index.js"
export const testUser: User = {
id: "test-user-1",
email: "[email protected]",
name: "Test User",
createdAt: new Date("2024-01-01")
}
export const testOrder: Order = {
id: "test-order-1",
userId: testUser.id,
items: [
{ productId: "prod-1", quantity: 2, price: 10.99 }
],
total: 21.98,
createdAt: new Date("2024-01-02")
}
export const makeUser = (overrides?: Partial<User>): User => ({
...testUser,
...overrides
})
Integration Testing
Test full application flows.tests/integration/checkout.test.ts
import { it } from "@effect/vitest"
import { Effect } from "effect"
import { AppLive } from "../../src/layers/app.js"
it.effect("should complete checkout flow", () =>
Effect.gen(function* () {
const userService = yield* UserService
const orderService = yield* OrderService
const paymentService = yield* PaymentService
// Create user
const user = yield* userService.create("[email protected]", "Buyer")
// Create order
const order = yield* orderService.create(user.id, [
{ productId: "prod-1", quantity: 1 }
])
// Process payment
const payment = yield* paymentService.process(order.id, order.total)
assert.strictEqual(payment.status, "completed")
assert.strictEqual(payment.amount, order.total)
}).pipe(
Effect.provide(AppLive)
)
)
Snapshot Testing
Test complex output structures.import { it } from "@effect/vitest"
import { Effect } from "effect"
it.effect("should match snapshot", () =>
Effect.gen(function* () {
const service = yield* ReportService
const report = yield* service.generateReport("2024-01")
expect(report).toMatchSnapshot()
}).pipe(
Effect.provide(TestLayer)
)
)
Testing Best Practices
- Use it.effect: Never run effects manually in tests
- Mock at boundaries: Mock external services, not business logic
- Test error paths: Verify error handling works correctly
- Use TestClock: Don’t use real timeouts in tests
- Test interruption: Verify cleanup happens correctly
- Isolate tests: Each test should be independent
- Use fixtures: Create reusable test data
- Test concurrency: Verify concurrent operations work correctly
Structure tests using the Arrange-Act-Assert pattern for clarity.
Common Patterns
Testing Database Operations
it.effect("should persist user", () =>
Effect.gen(function* () {
const db = yield* Database
const service = yield* UserService
const user = yield* service.create("[email protected]", "Test")
const found = yield* service.findById(user.id)
assert.deepStrictEqual(found, user)
}).pipe(
Effect.provide(TestDatabaseLayer)
)
)
Testing API Responses
it.effect("should return 404 for missing user", () =>
Effect.gen(function* () {
const response = yield* handleRequest({
method: "GET",
url: "/users/nonexistent"
})
assert.strictEqual(response.status, 404)
}).pipe(
Effect.provide(TestLayer)
)
)
Next Steps
- Learn about Project Structure
- Explore Type Safety
- Master Performance Optimization