| name | effect-testing |
| description | Write comprehensive tests using @effect/vitest for Effect code and vitest for pure functions. Use this skill when implementing tests for Effect-based applications, including services, layers, time-dependent effects, error handling, and property-based 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"
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"
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.
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.).
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.
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.
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 { expect } from "@effect/vitest"
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 {
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"
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:
it.effect("should work with dependencies", () =>
Effect.gen(function* () {
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 } from "@effect/vitest"
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", () => /* ... */)
})
Nested Layers
Compose layers for complex dependencies:
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:
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 { TestClock } 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:
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
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
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:
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:
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
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
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 { 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
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
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
it.effect.only("debug this test", () =>
Effect.gen(function* () {
// Only this test runs
})
)
Running Conditionally
it.effect.runIf(process.env.INTEGRATION_TESTS)("integration test", () =>
Effect.gen(function* () {
// Only runs if condition is true
})
)
Expecting Failures
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:
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)
it.effect("logs are suppressed", () =>
Effect.gen(function* () {
yield* Effect.log("This won't appear")
})
)
Enabling Logs
import { 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
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
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
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
// L Wrong - mixing assertion libraries
import { assert } from "@effect/vitest"
it.effect("test", () =>
Effect.gen(function* () {
assert.strictEqual(result, expected) // Don't use this
})
)
// Correct - use expect
import { expect } from "@effect/vitest"
it.effect("test", () =>
Effect.gen(function* () {
expect(result).toBe(expected)
})
)
Don't Forget to Fork for TestClock
// L 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
// L Wrong - providing to wrong level
it.effect("test", () =>
Effect.gen(function* () {
const result = yield* someEffect
expect(result).toBe(expected)
})
).pipe(Effect.provide(layer)) // L Can't provide to test function
// 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.