From 74b20d5e869e95801972809607eed3be906aadb5 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:12:13 +0100 Subject: [PATCH] =?UTF-8?q?testing:=20effectTest=20harness=20=E2=80=94=20r?= =?UTF-8?q?eturn=20an=20Effect,=20get=20pass/fail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests across the suite repeat `test(name, () => Effect.runPromise(Effect.scoped(Effect.gen(...))))`, and a failing Effect rejects with a FiberFailure that buries the real error/assertion behind the promise boundary. `effectTest(name, body, options?)` lets a test body return an Effect: - always wraps the body in `Effect.scoped`, giving every test a per-test Scope whose finalizers run on both the success and failure paths; - `options.layer` threads the test's requirements (R) without a global — the caller composes captureLogger / TestContext (TestClock) / the stub HttpClient layer and passes them as one layer. The base stays on the live clock, so TestClock is opt-in; - runs via `Effect.runPromiseExit` and, on failure, throws `Cause.squash(cause)` so the raw error surfaces — squash (not prettyErrors) preserves the original thrown object, keeping bun's assertion-diff renderer intact. `runTestEffect` is the engine behind the registrar, exported so the pass/fail mapping is itself testable. comms-30hq --- packages/testing/effect-test.test.ts | 125 +++++++++++++++++++++++++++ packages/testing/effect-test.ts | 90 +++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 packages/testing/effect-test.test.ts create mode 100644 packages/testing/effect-test.ts diff --git a/packages/testing/effect-test.test.ts b/packages/testing/effect-test.test.ts new file mode 100644 index 0000000..95e7534 --- /dev/null +++ b/packages/testing/effect-test.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test' +import { captureLogger } from '@commy/core/logging' +import { Context, Data, Effect, Layer, Ref, TestClock, TestContext } from 'effect' +import { effectTest, runTestEffect } from './effect-test.ts' + +class Boom extends Data.TaggedError('Boom')<{ readonly detail: string }> {} + +describe('runTestEffect', () => { + test('a succeeding Effect resolves with its value', async () => { + expect(await runTestEffect(() => Effect.succeed(42))).toBe(42) + }) + + test('a typed failure rejects with the raw error, not a FiberFailure', async () => { + const error = await runTestEffect(() => Effect.fail(new Boom({ detail: 'boom' }))).then( + () => undefined, + (caught: unknown) => caught, + ) + expect(error).toBeInstanceOf(Boom) + expect((error as Boom).detail).toBe('boom') + expect((error as object).constructor.name).not.toBe('FiberFailure') + }) + + test('a defect thrown in the body surfaces the original object, not a FiberFailure', async () => { + const thrown = new Error('expected 1 to be 2') + const error = await runTestEffect(() => + Effect.gen(function* () { + yield* Effect.void + throw thrown + }), + ).then( + () => undefined, + (caught: unknown) => caught, + ) + expect(error).toBe(thrown) + }) + + test('per-test Scope: finalizers run after the body completes', async () => { + const events: Array = [] + await runTestEffect(() => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.sync(() => events.push('finalized'))) + events.push('body') + }), + ) + expect(events).toEqual(['body', 'finalized']) + }) + + test('per-test Scope: finalizers run even when the body fails', async () => { + const events: Array = [] + await runTestEffect(() => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.sync(() => events.push('finalized'))) + return yield* new Boom({ detail: 'kaboom' }) + }), + ).catch(() => undefined) + expect(events).toEqual(['finalized']) + }) + + test('layer provision: a service from options.layer satisfies the body R', async () => { + class Greeting extends Context.Tag('test/Greeting')() {} + const captured: Array = [] + await runTestEffect( + () => + Effect.gen(function* () { + captured.push(yield* Greeting) + }), + { layer: Layer.succeed(Greeting, 'hello') }, + ) + expect(captured).toEqual(['hello']) + }) + + test('layer provision: captureLogger captures the body diagnostics', async () => { + const lines: Array = [] + await runTestEffect(() => Effect.logInfo('diagnostic'), { layer: captureLogger(lines) }) + expect(lines).toContain('diagnostic') + }) + + test('layer provision: TestContext makes TestClock available to the body', async () => { + const observed = await runTestEffect( + () => + Effect.gen(function* () { + yield* TestClock.setTime(5_000) + const ref = yield* Ref.make(0) + yield* Effect.sleep('5 seconds').pipe(Effect.zipRight(Ref.set(ref, 1)), Effect.fork) + yield* TestClock.adjust('5 seconds') + return yield* Ref.get(ref) + }), + { layer: TestContext.TestContext }, + ) + expect(observed).toBe(1) + }) + + test('layer build failure surfaces the raw layer error', async () => { + class Resource extends Context.Tag('test/Resource')() {} + const failingLayer = Layer.effect( + Resource, + Effect.fail(new Boom({ detail: 'layer build failed' })), + ) + const error = await runTestEffect(() => Effect.succeed(0), { layer: failingLayer }).then( + () => undefined, + (caught: unknown) => caught, + ) + expect(error).toBeInstanceOf(Boom) + expect((error as Boom).detail).toBe('layer build failed') + }) +}) + +describe('effectTest', () => { + effectTest('registers a passing test from a returning Effect', () => + Effect.gen(function* () { + expect(yield* Effect.succeed('ok')).toBe('ok') + }), + ) + + class Greeting extends Context.Tag('test/EffectTestGreeting')() {} + + effectTest( + 'provides options.layer to the body', + () => + Effect.gen(function* () { + expect(yield* Greeting).toBe('hi') + }), + { layer: Layer.succeed(Greeting, 'hi') }, + ) +}) diff --git a/packages/testing/effect-test.ts b/packages/testing/effect-test.ts new file mode 100644 index 0000000..fbc4779 --- /dev/null +++ b/packages/testing/effect-test.ts @@ -0,0 +1,90 @@ +/** + * Effect-native bun:test harness. + * + * Lets a test body **return** an Effect instead of hand-wrapping + * `Effect.runPromise(Effect.scoped(Effect.gen(...)))` at every call site. + * The harness owns three concerns the wrapper used to repeat by hand: + * + * 1. **Per-test Scope** — the body runs inside `Effect.scoped`, so a test can + * `Effect.addFinalizer` / acquire a scoped resource and its finalizers run + * when the test ends, on both the success and failure paths. + * 2. **Per-test layer provision** — `options.layer` is how a test threads its + * requirements (`R`) without a global: pass `captureLogger(lines)`, + * `TestContext.TestContext` (for `TestClock`), or the stub HttpClient layer, + * merged into one layer. The base harness stays on the live clock; TestClock + * is opt-in via that layer, so live-clock tests are unaffected. + * 3. **Real error surfacing** — `Effect.runPromise` rejects with a + * `FiberFailure` that buries the underlying error (the promise-boundary + * gotcha). The harness runs `Effect.runPromiseExit` and, on failure, throws + * `Cause.squash(cause)` — the raw failure value or defect. `squash` (rather + * than `Cause.prettyErrors`) deliberately returns the original thrown object + * untouched, so a failed `expect(...)` reaches bun's reporter with its + * matcher diff intact instead of a re-wrapped error. + */ + +import { test } from 'bun:test' +import { Cause, Effect, Exit, type Layer, type Scope } from 'effect' + +interface LayerOption { + readonly layer: Layer.Layer +} + +interface EffectTestOptions extends Partial> { + readonly timeout?: number +} + +/** + * Run a test-body Effect to a resolved/rejected Promise: a successful Effect + * resolves with its value, a failed one (typed error or defect) rejects with + * the squashed cause. The engine behind {@link effectTest}, exported so the + * pass/fail mapping is itself testable and so a caller can drive a returned + * Effect inside a custom `test.each` / table. + */ +export function runTestEffect(body: () => Effect.Effect): Promise +export function runTestEffect( + body: () => Effect.Effect, + options: LayerOption, +): Promise +export function runTestEffect( + body: () => Effect.Effect, + options?: LayerOption, +): Promise { + const scoped = Effect.scoped(Effect.suspend(body)) + const provided = + options === undefined + ? (scoped as Effect.Effect) + : Effect.provide(scoped, options.layer) + return Effect.runPromiseExit(provided).then((exit) => + Exit.isSuccess(exit) ? exit.value : Promise.reject(Cause.squash(exit.cause)), + ) +} + +/** + * Register a bun:test test whose body returns an Effect. The Effect runs inside + * a per-test Scope with `options.layer` provided; success passes, failure fails + * with the real error surfaced (see {@link runTestEffect}). + */ +export function effectTest( + name: string, + body: () => Effect.Effect, + options?: { readonly timeout?: number }, +): void +export function effectTest( + name: string, + body: () => Effect.Effect, + options: EffectTestOptions & LayerOption, +): void +export function effectTest( + name: string, + body: () => Effect.Effect, + options?: EffectTestOptions, +): void { + test( + name, + () => + options?.layer === undefined + ? runTestEffect(body as () => Effect.Effect) + : runTestEffect(body, { layer: options.layer }), + options?.timeout, + ) +}