effect-testing
Effect Testing Skill
This skill provides comprehensive guidance for testing Effect-based applications using @effect/vitest and standard vitest.
Framework Selection
CRITICAL: Choose the correct testing framework based on the code being tested.
Use @effect/vitest for Effect Code
Use @effect/vitest when testing:
- Functions that return
Effect<A, E, R> - Code that uses services and layers
- Time-dependent operations with
TestClock - Asynchronous operations coordinated with Effect
- STM (Software Transactional Memory) operations
import { it, expect } from "@effect/vitest"
import { Effect } from "effect"
declare const fetchUser: (id: string) => Effect.Effect<{ id: string }, Error>
it.effect("should fetch user", () =>
Effect.gen(function* () {
const user = yield* fetchUser("123")
expect(user.id).toBe("123")
})
)
Use Regular vitest for Pure Functions
Use standard vitest for:
- Pure functions with no Effect wrapper
- Simple data transformations
- Helper utilities
- Type constructors (brands, newtypes)
import { describe, expect, it } from "vitest"
declare const Cents: {
make: (value: bigint) => bigint
add: (a: bigint, b: bigint) => bigint
}
describe("Cents", () => {
it("should add cents correctly", () => {
const result = Cents.add(Cents.make(100n), Cents.make(50n))
expect(result).toBe(150n)
})
})
Test Variants
it.effect - Default Test Environment
Provides TestContext including TestClock, TestRandom, etc.
import { it, expect } from "@effect/vitest"
import { Effect } from "effect"
declare const someEffect: Effect.Effect<number>
declare const expected: number
it.effect("test name", () =>
Effect.gen(function* () {
// Test implementation with TestContext available
const result = yield* someEffect
expect(result).toBe(expected)
})
)
it.live - Live Environment
Uses real services (real clock, real random, etc.).
import { it } from "@effect/vitest"
import { Effect, Clock } from "effect"
it.live("test with real time", () =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
// Uses actual system time
})
)
it.scoped - Resource Management
For tests requiring Scope to manage resource lifecycle.
import { it } from "@effect/vitest"
import { Effect } from "effect"
declare const acquire: Effect.Effect<unknown>
declare const release: Effect.Effect<void>
it.scoped("test with resources", () =>
Effect.gen(function* () {
const resource = yield* Effect.acquireRelease(
acquire,
() => release
)
// Resource automatically cleaned up after test
})
)
it.scopedLive - Combined Scoped + Live
Uses live environment with scope for resource management.
import { it } from "@effect/vitest"
import { Effect } from "effect"
declare const acquireRealResource: Effect.Effect<unknown>
declare const releaseRealResource: Effect.Effect<void>
it.scopedLive("live test with resources", () =>
Effect.gen(function* () {
const resource = yield* Effect.acquireRelease(
acquireRealResource,
() => releaseRealResource
)
})
)
Assertions
Use expect from vitest
For all assertions, use the standard expect from vitest:
import { it, expect } from "@effect/vitest"
import { Effect } from "effect"
declare const computation: Effect.Effect<number>
declare const array: unknown[]
it.effect("assertions", () =>
Effect.gen(function* () {
const result = yield* computation
expect(result).toBe(42)
expect(result).toBeGreaterThan(0)
expect(array).toHaveLength(3)
})
)
Effect-Specific Utilities
@effect/vitest provides additional assertion utilities in utils:
import { it } from "@effect/vitest"
import {
assertEquals, // Uses Effect's Equal.equals
assertTrue,
assertFalse,
assertSome, // For Option.Some
assertNone, // For Option.None
assertRight, // For Either.Right
assertLeft, // For Either.Left
assertSuccess, // For Exit.Success
assertFailure // For Exit.Failure
} from "@effect/vitest/utils"
import { Effect, Option, Either } from "effect"
declare const someOptionalEffect: Effect.Effect<Option.Option<number>>
declare const someEitherEffect: Effect.Effect<Either.Either<number, Error>>
declare const expectedValue: number
it.effect("with effect assertions", () =>
Effect.gen(function* () {
const option = yield* someOptionalEffect
assertSome(option, expectedValue)
const either = yield* someEitherEffect
assertRight(either, expectedValue)
})
)
Testing with Services and Layers
Providing Services to Tests
Use Effect.provide to supply test implementations:
import { it, expect } from "@effect/vitest"
import { Effect, Context, Layer } from "effect"
class UserService extends Context.Tag("UserService")<UserService, {
getUser: (id: string) => Effect.Effect<{ name: string }>
}>() {}
declare const TestUserServiceLayer: Layer.Layer<UserService>
it.effect("should work with dependencies", () =>
Effect.gen(function* () {
const userService = yield* UserService
const result = yield* userService.getUser("123")
expect(result.name).toBe("John")
}).pipe(Effect.provide(TestUserServiceLayer))
)
Using layer Helper
Share a layer across multiple tests with the layer function:
import { layer, it, expect } from "@effect/vitest"
import { Effect, Context, Layer } from "effect"
class Database extends Context.Tag("Database")<Database, {
query: (sql: string) => Effect.Effect<Array<unknown>>
}>() {
static Test = Layer.succeed(Database, {
query: (sql) => Effect.succeed([])
})
}
layer(Database.Test)((it) => {
it.effect("test 1", () =>
Effect.gen(function* () {
const db = yield* Database
const results = yield* db.query("SELECT *")
expect(results).toEqual([])
})
)
it.effect("test 2", () =>
Effect.gen(function* () {
const db = yield* Database
// Database available in all tests
})
)
})
// With name for describe block
layer(Database.Test)("Database tests", (it) => {
it.effect("query test", () => Effect.succeed(true))
})
Nested Layers
Compose layers for complex dependencies:
import { layer, it } from "@effect/vitest"
import { Effect, Context, Layer } from "effect"
class Database extends Context.Tag("Database")<Database, {
query: (sql: string) => Effect.Effect<Array<unknown>>
}>() {}
class UserService extends Context.Tag("UserService")<UserService, {
getUser: (id: string) => Effect.Effect<unknown>
}>() {}
declare const DatabaseLayer: Layer.Layer<Database>
declare const UserServiceLayer: Layer.Layer<UserService, never, Database>
layer(DatabaseLayer)((it) => {
it.layer(UserServiceLayer)("user tests", (it) => {
it.effect("has both dependencies", () =>
Effect.gen(function* () {
const db = yield* Database
const userService = yield* UserService
// Both available
})
)
})
})
Excluding Test Services
Use live services instead of test services:
import { layer, it } from "@effect/vitest"
import { Effect, Layer } from "effect"
declare const MyServiceLayer: Layer.Layer<never>
layer(MyServiceLayer, { excludeTestServices: true })((it) => {
it.effect("uses real clock", () =>
Effect.gen(function* () {
// Uses actual Clock, not TestClock
})
)
})
Time-Dependent Testing with TestClock
Basic TestClock Usage
TestClock allows controlling time without waiting:
import { it, expect } from "@effect/vitest"
import { Effect, TestClock, Fiber } from "effect"
it.effect("should handle delays", () =>
Effect.gen(function* () {
const fiber = yield* Effect.fork(
Effect.sleep("5 seconds").pipe(Effect.as("done"))
)
// Advance time by 5 seconds instantly
yield* TestClock.adjust("5 seconds")
const result = yield* Fiber.join(fiber)
expect(result).toBe("done")
})
)
Testing Recurring Effects
Test periodic operations efficiently:
import { it, expect } from "@effect/vitest"
import { Effect, Queue, TestClock, Option } from "effect"
it.effect("should execute every minute", () =>
Effect.gen(function* () {
const queue = yield* Queue.unbounded<number>()
// Fork effect that repeats every minute
yield* Effect.fork(
Queue.offer(queue, 1).pipe(
Effect.delay("60 seconds"),
Effect.forever
)
)
// No effect before time passes
const empty = yield* Queue.poll(queue)
expect(Option.isNone(empty)).toBe(true)
// Advance time
yield* TestClock.adjust("60 seconds")
// Effect executed once
const value = yield* Queue.take(queue)
expect(value).toBe(1)
// Verify only one execution
const stillEmpty = yield* Queue.poll(queue)
expect(Option.isNone(stillEmpty)).toBe(true)
})
)
Testing Clock Methods
import { it, expect } from "@effect/vitest"
import { Effect, Clock, TestClock } from "effect"
it.effect("should track time correctly", () =>
Effect.gen(function* () {
const start = yield* Clock.currentTimeMillis
yield* TestClock.adjust("1 minute")
const end = yield* Clock.currentTimeMillis
expect(end - start).toBeGreaterThanOrEqual(60_000)
})
)
TestClock with Deferred
import { it, expect } from "@effect/vitest"
import { Effect, Deferred, TestClock } from "effect"
it.effect("should handle deferred with delays", () =>
Effect.gen(function* () {
const deferred = yield* Deferred.make<number, void>()
yield* Effect.fork(
Effect.sleep("10 seconds").pipe(
Effect.zipRight(Deferred.succeed(deferred, 42))
)
)
yield* TestClock.adjust("10 seconds")
const result = yield* Deferred.await(deferred)
expect(result).toBe(42)
})
)
Error Testing
Testing Expected Failures
Use Effect.flip to convert failures to successes:
import { it, expect } from "@effect/vitest"
import { Effect, Data } from "effect"
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
userId: string
}> {}
declare const failingOperation: () => Effect.Effect<never, UserNotFoundError>
it.effect("should fail with error", () =>
Effect.gen(function* () {
const error = yield* Effect.flip(failingOperation())
expect(error).toBeInstanceOf(UserNotFoundError)
expect(error.userId).toBe("123")
})
)
Testing with Exit
Use Effect.exit to capture both success and failure:
import { it, expect } from "@effect/vitest"
import { Effect, Exit } from "effect"
declare const divide: (a: number, b: number) => Effect.Effect<number, string>
it.effect("should handle success", () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(divide(4, 2))
expect(exit).toEqual(Exit.succeed(2))
})
)
it.effect("should handle failure", () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(divide(4, 0))
expect(exit).toEqual(Exit.fail("Cannot divide by zero"))
})
)
Testing Error Types
import { it, expect } from "@effect/vitest"
import { Effect, Exit, Cause, Context, Data } from "effect"
class NotFoundError extends Data.TaggedError("NotFoundError")<{
id: string
}> {}
class UserService extends Context.Tag("UserService")<UserService, {
getUser: (id: string) => Effect.Effect<unknown, NotFoundError>
}>() {}
declare const userService: {
getUser: (id: string) => Effect.Effect<unknown, NotFoundError>
}
it.effect("should fail with specific error", () =>
Effect.gen(function* () {
const exit = yield* Effect.exit(
userService.getUser("nonexistent")
)
if (Exit.isFailure(exit)) {
const cause = exit.cause
expect(Cause.isFailType(cause)).toBe(true)
const error = Cause.failureOrCause(cause)
expect(error).toBeInstanceOf(NotFoundError)
} else {
throw new Error("Expected failure")
}
})
)
Property-Based Testing
Using it.prop for Pure Properties
import { FastCheck } from "effect"
import { it } from "@effect/vitest"
it.prop(
"addition is commutative",
[FastCheck.integer(), FastCheck.integer()],
([a, b]) => a + b === b + a
)
// With object syntax
it.prop(
"multiplication distributes",
{ a: FastCheck.integer(), b: FastCheck.integer(), c: FastCheck.integer() },
({ a, b, c }) => a * (b + c) === a * b + a * c
)
Using it.effect.prop for Effect Properties
import { it } from "@effect/vitest"
import { Effect, Context } from "effect"
import { FastCheck } from "effect"
class Database extends Context.Tag("Database")<Database, {
set: (key: string, value: number) => Effect.Effect<void>
get: (key: string) => Effect.Effect<number>
}>() {}
it.effect.prop(
"database operations are idempotent",
[FastCheck.string(), FastCheck.integer()],
([key, value]) =>
Effect.gen(function* () {
const db = yield* Database
yield* db.set(key, value)
const result1 = yield* db.get(key)
yield* db.set(key, value)
const result2 = yield* db.get(key)
return result1 === result2
})
)
With Schema Arbitraries
import { it, expect } from "@effect/vitest"
import { Effect, Schema } from "effect"
const User = Schema.Struct({
id: Schema.String,
age: Schema.Number.pipe(Schema.between(0, 120))
})
it.effect.prop(
"user validation works",
{ user: User },
({ user }) =>
Effect.gen(function* () {
expect(user.age).toBeGreaterThanOrEqual(0)
expect(user.age).toBeLessThanOrEqual(120)
return true
})
)
Configuring FastCheck
import { it } from "@effect/vitest"
import { Effect, FastCheck } from "effect"
it.effect.prop(
"property test",
[FastCheck.integer()],
([n]) => Effect.succeed(n >= 0 || n < 0),
{
timeout: 10000,
fastCheck: {
numRuns: 1000,
seed: 42,
verbose: true
}
}
)
Test Control
Skipping Tests
import { it } from "@effect/vitest"
import { Effect } from "effect"
declare const condition: boolean
it.effect.skip("not ready yet", () =>
Effect.gen(function* () {
// Will not run
})
)
it.effect.skipIf(condition)("conditional skip", () =>
Effect.gen(function* () {
// Only runs if condition is false
})
)
Running Single Tests
import { it } from "@effect/vitest"
import { Effect } from "effect"
it.effect.only("debug this test", () =>
Effect.gen(function* () {
// Only this test runs
})
)
Running Conditionally
import { it } from "@effect/vitest"
import { Effect } from "effect"
it.effect.runIf(process.env.INTEGRATION_TESTS)("integration test", () =>
Effect.gen(function* () {
// Only runs if condition is true
})
)
Expecting Failures
import { it, expect } from "@effect/vitest"
import { Effect } from "effect"
it.effect.fails("known failing test", () =>
Effect.gen(function* () {
// This test is expected to fail
// Will pass if it fails, fail if it passes
expect(1).toBe(2)
})
)
Testing Flaky Operations
Use it.flakyTest for operations that may fail intermittently:
import { it } from "@effect/vitest"
import { Effect, Random } from "effect"
it.effect("retrying flaky operation", () =>
it.flakyTest(
Effect.gen(function* () {
const random = yield* Random.nextBoolean
if (random) {
yield* Effect.fail("Random failure")
}
}),
"5 seconds" // Retry timeout
)
)
Logging in Tests
Default Behavior (Suppressed)
import { it } from "@effect/vitest"
import { Effect } from "effect"
it.effect("logs are suppressed", () =>
Effect.gen(function* () {
yield* Effect.log("This won't appear")
})
)
Enabling Logs
import { it } from "@effect/vitest"
import { Effect, Logger } from "effect"
it.effect("logs visible", () =>
Effect.gen(function* () {
yield* Effect.log("This will appear")
}).pipe(Effect.provide(Logger.pretty))
)
// Or use it.live
it.live("logs visible", () =>
Effect.gen(function* () {
yield* Effect.log("This will appear")
})
)
Testing Patterns
Arrange-Act-Assert Pattern
import { describe, it, expect } from "@effect/vitest"
import { Effect, Context, Layer } from "effect"
class UserService extends Context.Tag("UserService")<UserService, {
getUser: (id: string) => Effect.Effect<{ id: string; name: string }>
}>() {}
declare const TestUserServiceLayer: Layer.Layer<UserService>
describe("UserService", () => {
describe("getUser", () => {
it.effect("should return user by id", () =>
Effect.gen(function* () {
// Arrange
const userId = "user-123"
const expectedUser = { id: userId, name: "Alice" }
// Act
const service = yield* UserService
const user = yield* service.getUser(userId)
// Assert
expect(user).toEqual(expectedUser)
}).pipe(Effect.provide(TestUserServiceLayer))
)
})
})
Testing STM Operations
import { it, expect } from "@effect/vitest"
import { Effect, STM, TRef } from "effect"
it.effect("should handle concurrent updates", () =>
Effect.gen(function* () {
const counter = yield* TRef.make(0)
const increment = STM.updateAndGet(counter, (n) => n + 1)
yield* STM.commit(increment)
yield* STM.commit(increment)
const final = yield* STM.commit(TRef.get(counter))
expect(final).toBe(2)
})
)
Testing CRDT Operations
import { it, expect } from "@effect/vitest"
import { Effect, STM } from "effect"
declare const GCounter: {
make: (id: string) => Effect.Effect<unknown>
increment: (counter: unknown, value: number) => STM.STM<void>
query: (counter: unknown) => STM.STM<unknown>
merge: (counter: unknown, state: unknown) => STM.STM<void>
value: (counter: unknown) => STM.STM<number>
}
declare const ReplicaId: (id: string) => string
it.effect("should merge states correctly", () =>
Effect.gen(function* () {
const counter1 = yield* GCounter.make(ReplicaId("replica-1"))
const counter2 = yield* GCounter.make(ReplicaId("replica-2"))
yield* STM.commit(GCounter.increment(counter1, 10))
yield* STM.commit(GCounter.increment(counter2, 20))
const state2 = yield* STM.commit(GCounter.query(counter2))
yield* STM.commit(GCounter.merge(counter1, state2))
const result = yield* STM.commit(GCounter.value(counter1))
expect(result).toBe(30)
})
)
Testing Checklist
Before completing a testing task, verify:
- Correct framework chosen (@effect/vitest vs vitest)
- Test variant appropriate (effect/live/scoped/scopedLive)
- Services provided via layers when needed
- TestClock used for time-dependent operations
- Errors tested with Effect.flip or Effect.exit
- Edge cases covered
- Property-based tests for general properties
- Tests are deterministic (no real time, real random unless intended)
- Test names describe behavior clearly
- Resources properly scoped and cleaned up
- All tests pass
Common Pitfalls
Don't Mix expect with assert
// ❌ Wrong - mixing assertion libraries
import { it } from "@effect/vitest"
import { Effect } from "effect"
declare const result: unknown
declare const expected: unknown
it.effect("test", () =>
Effect.gen(function* () {
// assert.strictEqual(result, expected) // Don't use this
})
)
// ✅ Correct - use expect
import { it, expect } from "@effect/vitest"
it.effect("test", () =>
Effect.gen(function* () {
expect(result).toBe(expected)
})
)
Don't Forget to Fork for TestClock
import { it } from "@effect/vitest"
import { Effect, TestClock, Fiber } from "effect"
// ❌ Wrong - will hang waiting for real time
it.effect("test", () =>
Effect.gen(function* () {
yield* Effect.sleep("5 seconds") // Blocks!
yield* TestClock.adjust("5 seconds")
})
)
// ✅ Correct - fork the effect
it.effect("test", () =>
Effect.gen(function* () {
const fiber = yield* Effect.fork(Effect.sleep("5 seconds"))
yield* TestClock.adjust("5 seconds")
yield* Fiber.join(fiber)
})
)
Provide Layers to Effect, Not Test
import { it, expect } from "@effect/vitest"
import { Effect, Layer } from "effect"
declare const someEffect: Effect.Effect<number>
declare const expected: number
declare const layer: Layer.Layer<never>
// ❌ Wrong - providing to wrong level
it.effect("test", () =>
Effect.gen(function* () {
const result = yield* someEffect
expect(result).toBe(expected)
})
) // ❌ Can't provide to test function
// .pipe(Effect.provide(layer))
// ✅ Correct - provide to Effect
it.effect("test", () =>
Effect.gen(function* () {
const result = yield* someEffect
expect(result).toBe(expected)
}).pipe(Effect.provide(layer)) // ✅ Provide to Effect
)
Running Tests
# Run all tests
bun run test
# Watch mode
bun run test:watch
# Run specific file
bun run test path/to/file.test.ts
# Run with coverage
bun run test --coverage
Example: Complete Test Suite
import { describe, expect, it, layer } from "@effect/vitest"
import { Effect, Context, Layer, TestClock, Exit } from "effect"
// Service definition
class Counter extends Context.Tag("Counter")<Counter, {
increment: () => Effect.Effect<void>
value: () => Effect.Effect<number>
}>() {
static Live = Layer.effect(
Counter,
Effect.gen(function* () {
let count = 0
return {
increment: () => Effect.sync(() => { count++ }),
value: () => Effect.succeed(count)
}
})
)
}
// Tests
layer(Counter.Live)("Counter", (it) => {
it.effect("should start at 0", () =>
Effect.gen(function* () {
const counter = yield* Counter
const value = yield* counter.value()
expect(value).toBe(0)
})
)
it.effect("should increment", () =>
Effect.gen(function* () {
const counter = yield* Counter
yield* counter.increment()
const value = yield* counter.value()
expect(value).toBe(1)
})
)
it.effect("should handle multiple increments", () =>
Effect.gen(function* () {
const counter = yield* Counter
yield* counter.increment()
yield* counter.increment()
yield* counter.increment()
const value = yield* counter.value()
expect(value).toBe(3)
})
)
})
This skill ensures comprehensive, reliable testing of Effect-based applications following best practices.
More from front-depiction/claude-setup
domain-modeling
Create production-ready Effect domain models using Schema.TaggedStruct for ADTs, Schema.Data for automatic equality, with comprehensive predicates, orders, guards, and match functions. Use when modeling domain entities, value objects, or any discriminated union types.
10context-witness
Decide between Context Tag witness and capability patterns for dependency injection, understanding coupling trade-offs
7pattern-matching
Master Effect pattern matching using Data.TaggedEnum, $match, $is, Match.typeTags, and Effect.match. Avoid manual _tag checks and Effect.either patterns. Use this skill when working with discriminated unions, ADTs, or conditional logic based on tagged types.
7effect-ai-provider
Configure and compose AI provider layers using @effect/ai packages. Covers Anthropic, OpenAI, OpenRouter, Google, and Amazon Bedrock providers with config management, model abstraction, and runtime overrides for language model integration.
7service-implementation
Implement Effect services as fine-grained capabilities avoiding monolithic designs
7effect-ai-language-model
Master the Effect AI LanguageModel service for text generation, structured output, streaming, and tool calling. Use when working with LLM interactions, schema-validated responses, or building conversational AI systems.
7