Testing
Test behavior, wiring and lifetimes without a global container.
Testing is one of the main reasons to keep Services and Layers separate. The Service contains behavior; the test Layer supplies controlled implementations.
Use an isolated TestRuntime
better-effect/testing provides a thin facade over the real Layer and
Runtime. It creates a fresh backend, records Runtime lifecycle events, and
releases all resources when the test finishes. Standard test Services are
installed only when they are passed explicitly:
import { Result } from 'better-result'
import { Effect } from 'better-effect'
import { Clock, Logger, Random } from 'better-effect/standard-services'
import { ClockTest, LoggerTest, RandomSeeded, TestRuntime } from 'better-effect/testing'
const logger = new LoggerTest()
const result = await TestRuntime.use(
AppLive,
{
overrides: [DatabaseTest],
clock: new ClockTest(new Date('2026-01-01T00:00:00Z')),
logger,
random: new RandomSeeded(42)
},
async (test) => {
const value = await test.run(
Effect.fn(async function* () {
const clock = yield* Clock
const runtimeLogger = yield* Logger
const random = yield* Random
runtimeLogger.info('loading', { sample: random.nextInt(10) })
return Result.ok({ at: clock.now() })
})
)
expect(test.observer.executionEnds).toHaveLength(1)
expect(test.logger.events).toHaveLength(1)
return value
}
)The long-lived form supports JavaScript async disposal and the same typed
run/runWith methods:
await using test = await TestRuntime.make(AppLive, {
overrides: [DatabaseTest]
})
const result = await test.run(loadDashboard)
const requestResult = await test.runWith(RequestLive, handleRequest)TestRuntime.use disposes in finally; a program failure remains primary over
cleanup failure. Disposal is idempotent, and two TestRuntime instances can run
concurrently without sharing backend or resolver state. The recorded observer
is available as test.observer. The underlying Runtime is available as
test.runtime only for advanced test cases; normal tests should use the facade.
Use the built-in backend
const runtime = await Runtime.make(AppTest)
const result = await runtime.run(applicationProgram)
await runtime.dispose()The default MapLayerBackend is lazy, caches by Service tag, and clears its
state during disposeAll.
For a single test, avoid keeping a Runtime around:
const result = await Runtime.run(AppTest, applicationProgram)Drive time without fake timers
Use ClockTest for polling, retry and expiration code without replacing global
Date or setTimeout:
const clock = new ClockTest(0)
const task = (async () => {
await clock.sleep(100)
await clock.sleep(50)
})()
expect(clock.pendingSleeps).toBe(1)
expect(clock.advanceToNext()).toBe(true)
await clock.runAll({ maxSteps: 10 })
await taskSleeps are queued by ascending absolute deadline, with FIFO order for equal
deadlines. advanceToNext() is synchronous and returns false when the queue
is empty; runAll() returns the number of deadline steps and awaits one
microtask checkpoint between steps so resumed code can enqueue another sleep.
Its maxSteps option guards against endlessly rescheduled waits. setTime()
may move backward, while pending sleeps retain their absolute deadlines.
Production and test sleeps accept { signal }; cancellation removes the timer,
listener or pending waiter and rejects with the signal reason.
Override one provider
const DatabaseTest = Layer.succeed(
Database,
Database.of({
findById: async () => Result.ok({ id: 'test-user' })
})
)
const AppTest = Layer.override(AppLive, DatabaseTest)Only the explicit replacement changes; other production providers remain in
the environment. If the fake's contract is incompatible with the production
Service, TypeScript rejects the Layer.override call instead of letting an
invalid provider cross the Runtime boundary.
Resolver context isolation
ServiceRuntime and Scope share one RuntimeContext. The published Runtime
entrypoint is officially supported on Node.js and Bun. Its default context
storage uses AsyncLocalStorage, so tests should enter context through
ServiceRuntime.run or a Runtime boundary:
const applicationProgram = Effect.fn(async function* () {
const repository = yield* UserRepository
const user = yield* Result.await(repository.findById('test-user'))
return Result.ok(user)
})
const result = await runtime.run(applicationProgram)On the default Node/Bun storage, concurrent Runtime executions remain isolated. Avoid global “reset the container” helpers that create serial-test coupling.
The better-effect/runtime/explicit subpath provides
ExplicitRuntimeContextStorage as a manually managed, sequential strategy only
when the package entrypoint and host can load it. One storage instance supports
only one non-overlapping async flow and rejects concurrent overlap:
import { ExplicitRuntimeContextStorage } from 'better-effect/runtime/explicit'
const runtime = await Runtime.make(AppTest, {
contextStorage: new ExplicitRuntimeContextStorage()
})Use the default Node/Bun storage for overlapping asynchronous executions.
Using explicit storage does not make the package generally usable in browsers,
Deno, Cloudflare Workers, or other non-Node hosts. Separate explicit instances
do not provide general concurrent isolation; do not share one
ExplicitRuntimeContextStorage instance across concurrent Runtime executions.
Record Runtime lifecycle
better-effect/testing provides a recorder for assertions over every Runtime
observer event. Compose it with an application observer when a test needs both:
import { RecordedRuntimeObserver, RuntimeObserver } from 'better-effect/testing'
const testLog: string[] = []
const recorded = RecordedRuntimeObserver.make()
const runtime = await Runtime.make(AppTest, {
observers: [
RuntimeObserver.compose(recorded, {
onExecutionEnd: ({ outcome }) => testLog.push(outcome.status)
})
]
})
try {
const result = await runtime.run(applicationProgram)
expect(Result.isOk(result)).toBe(true)
expect(recorded.executionStarts).toHaveLength(1)
expect(recorded.executionEnds).toHaveLength(1)
expect(recorded.serviceAcquisitions).toHaveLength(1)
} finally {
await runtime.dispose()
}
const snapshot = recorded.snapshot()
expect(snapshot.timeline).toContain(snapshot.executionEnds[0])
recorded.clear()Category views and snapshot() are immutable copies that retain the original
Runtime event objects. timeline preserves their cross-category invocation
order. Execution start/end pairs can be matched by executionId; they also
carry the Program name, readonly per-run attributes, and non-negative duration.
clear() resets the recorder so one instance can be reused in a test. Observers
run in declaration order, and a throwing or rejecting observer never prevents
later observers from seeing an event or changes the Runtime result.
Type-contract tests
Type inference is part of the API. Use expectTypeOf in tests/types:
const program = Effect.fn(async function* () {
const service = yield* AuthService
return Result.ok(service)
})
expectTypeOf<Awaited<ReturnType<typeof program>>>().toEqualTypeOf<
Effect<AuthService, never, AuthService>
>()
expectTypeOf<EffectRequirements<typeof program>>().toEqualTypeOf<AuthService>()Protect contracts such as:
yield* AuthServiceinfers exactlyAuthService;yield* AuthServiceinfers exactlyAuthService;Layer.mergerejects duplicate tags;Runtime.runrejects missing program requirements;Layer.overriderejects incompatible same-tag contracts;- exported Service subclasses retain declaration-only identity on TypeScript 6+;
- generated JavaScript contains no Service identity or Effect requirement metadata;
Effect.addaccepts only disposable values.
Test cleanup behavior
Test both the happy path and the boundary guarantees:
Resource.acquireUseRelease must attempt release after every successful acquisition, even when
use returns an Err, throws or rejects.
Start a program that is still pending, call dispose, and assert that root cleanup happens only
after the execution settles.
Register multiple finalizers and nested children; assert children close before parents and newer registrations close before older ones.
Recommended test layout
Runtime tests belong in tests/*.test.ts; compile-time contracts belong in
tests/types/. Use Bun's bun:test runner.