better-effect
Core concepts

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 task

Sleeps 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* AuthService infers exactly AuthService;
  • yield* AuthService infers exactly AuthService;
  • Layer.merge rejects duplicate tags;
  • Runtime.run rejects missing program requirements;
  • Layer.override rejects 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.add accepts only disposable values.

Test cleanup behavior

Test both the happy path and the boundary guarantees:

service.test.ts
layer-runtime.test.ts
resource.test.ts
scope.test.ts

Runtime tests belong in tests/*.test.ts; compile-time contracts belong in tests/types/. Use Bun's bun:test runner.

On this page