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 the memory backend
import { MemoryLayerBackend } from 'better-effect/testing'
const backend = new MemoryLayerBackend()
const runtime = await Runtime.make(AppTest, backend)
const result = await runtime.run(() => applicationProgram)
await runtime.dispose()The backend is lazy, caches by Service tag, and clears its state during
disposeAll.
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 reports an override collision instead of letting an invalid provider cross the Runtime boundary.
Resolver context isolation
ServiceRuntime uses AsyncLocalStorage, so tests should enter context through
ServiceRuntime.run or a Runtime boundary:
const result = await runtime.run(() =>
Effect.gen(async function* () {
const repository = yield* UserRepository
return yield* Result.await(repository.findById('test-user'))
})
)Concurrent Runtime executions remain isolated. Avoid global “reset the container” helpers that create serial-test coupling.
Type-contract tests
Type inference is part of the API. Use expectTypeOf in tests/types:
const program = Effect.gen(function* () {
const service = yield* AuthService
return Result.ok(service)
})
expectTypeOf(service).toEqualTypeOf<AuthService>()
expectTypeOf<EffectRequirements<typeof program>>().toEqualTypeOf<AuthService>()Protect contracts such as:
yield* AuthServiceinfers exactlyAuthService;ServiceRuntime.resolve(AuthService)resolves toAuthService;Layer.mergerejects duplicate tags;Runtime.runrejects missing program requirements;Layer.overriderejects incompatible same-tag contracts;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.