Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions packages/testing/effect-test.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = []
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<string> = []
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')<Greeting, string>() {}
const captured: Array<string> = []
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<string> = []
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')<Resource, string>() {}
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')<Greeting, string>() {}

effectTest(
'provides options.layer to the body',
() =>
Effect.gen(function* () {
expect(yield* Greeting).toBe('hi')
}),
{ layer: Layer.succeed(Greeting, 'hi') },
)
})
90 changes: 90 additions & 0 deletions packages/testing/effect-test.ts
Original file line number Diff line number Diff line change
@@ -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<RIn, RErr> {
readonly layer: Layer.Layer<RIn, RErr>
}

interface EffectTestOptions<RIn, RErr> extends Partial<LayerOption<RIn, RErr>> {
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<A, E>(body: () => Effect.Effect<A, E, Scope.Scope>): Promise<A>
export function runTestEffect<A, E, RIn, RErr>(
body: () => Effect.Effect<A, E, RIn | Scope.Scope>,
options: LayerOption<RIn, RErr>,
): Promise<A>
export function runTestEffect<A, E, RIn, RErr>(
body: () => Effect.Effect<A, E, RIn | Scope.Scope>,
options?: LayerOption<RIn, RErr>,
): Promise<A> {
const scoped = Effect.scoped(Effect.suspend(body))
const provided =
options === undefined
? (scoped as Effect.Effect<A, E | RErr>)
: 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<A, E>(
name: string,
body: () => Effect.Effect<A, E, Scope.Scope>,
options?: { readonly timeout?: number },
): void
export function effectTest<A, E, RIn, RErr>(
name: string,
body: () => Effect.Effect<A, E, RIn | Scope.Scope>,
options: EffectTestOptions<RIn, RErr> & LayerOption<RIn, RErr>,
): void
export function effectTest<A, E, RIn, RErr>(
name: string,
body: () => Effect.Effect<A, E, RIn | Scope.Scope>,
options?: EffectTestOptions<RIn, RErr>,
): void {
test(
name,
() =>
options?.layer === undefined
? runTestEffect(body as () => Effect.Effect<A, E, Scope.Scope>)
: runTestEffect(body, { layer: options.layer }),
options?.timeout,
)
}