effect-bun-testing 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ import {
2
+ Cause,
3
+ Duration,
4
+ Effect,
5
+ Exit,
6
+ Layer,
7
+ pipe,
8
+ Schedule,
9
+ Scope
10
+ } from "effect"
11
+ import * as TestContext from "effect/TestContext"
12
+ import * as Logger from "effect/Logger"
13
+ import {
14
+ describe,
15
+ beforeAll,
16
+ afterAll,
17
+ test as bunTest,
18
+ expect
19
+ } from "bun:test"
20
+ import * as fc from "fast-check"
21
+ import * as Equal from "effect/Equal"
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** A test body that returns an Effect. */
28
+ export interface TestFunction<A, E, R> {
29
+ (): Effect.Effect<A, E, R>
30
+ }
31
+
32
+ /** Registers a named test whose body returns an Effect. */
33
+ export interface Test<R> {
34
+ <A, E>(
35
+ name: string,
36
+ self: TestFunction<A, E, R>,
37
+ timeout?: number
38
+ ): void
39
+ }
40
+
41
+ /** Full tester with modifiers (skip, only, each, etc.). */
42
+ export interface Tester<R> extends Test<R> {
43
+ readonly skip: Test<R>
44
+ readonly only: Test<R>
45
+ readonly skipIf: (condition: unknown) => Test<R>
46
+ readonly if: (condition: unknown) => Test<R>
47
+ /** Alias for `if` (vitest compat). */
48
+ readonly runIf: (condition: unknown) => Test<R>
49
+ readonly each: <T>(
50
+ cases: ReadonlyArray<T>
51
+ ) => <A, E>(
52
+ name: string,
53
+ self: (args: T) => Effect.Effect<A, E, R>,
54
+ timeout?: number
55
+ ) => void
56
+ readonly failing: Test<R>
57
+ /** Alias for `failing` (vitest compat). */
58
+ readonly fails: Test<R>
59
+ readonly prop: PropEffect<R>
60
+ }
61
+
62
+ /** Property-based test that returns an Effect. */
63
+ export interface PropEffect<R> {
64
+ <const Arbs extends ReadonlyArray<fc.Arbitrary<any>>>(
65
+ name: string,
66
+ arbitraries: Arbs,
67
+ self: (
68
+ ...args: { [K in keyof Arbs]: Arbs[K] extends fc.Arbitrary<infer T> ? T : never }
69
+ ) => Effect.Effect<any, any, R>,
70
+ timeout?: number | { readonly fastCheck?: fc.Parameters<any> }
71
+ ): void
72
+ }
73
+
74
+ /** Property-based test that returns void / Promise<void>. */
75
+ export interface Prop {
76
+ <const Arbs extends ReadonlyArray<fc.Arbitrary<any>>>(
77
+ name: string,
78
+ arbitraries: Arbs,
79
+ self: (
80
+ ...args: { [K in keyof Arbs]: Arbs[K] extends fc.Arbitrary<infer T> ? T : never }
81
+ ) => void | Promise<void>,
82
+ timeout?: number | { readonly fastCheck?: fc.Parameters<any> }
83
+ ): void
84
+ }
85
+
86
+ export type TestServices = never
87
+
88
+ /** Full method set exposed on `it` and returned from `makeMethods`. */
89
+ export interface Methods {
90
+ readonly effect: Tester<never>
91
+ readonly scoped: Tester<Scope.Scope>
92
+ readonly live: Tester<never>
93
+ readonly scopedLive: Tester<Scope.Scope>
94
+ readonly flakyTest: typeof flakyTest
95
+ readonly layer: typeof layer
96
+ readonly prop: Prop
97
+ }
98
+
99
+ /** Method set available inside a `layer()` callback. */
100
+ export interface MethodsNonLive<R, ExcludeTestServices extends boolean = false> {
101
+ readonly effect: Tester<R>
102
+ readonly scoped: Tester<R | Scope.Scope>
103
+ readonly flakyTest: typeof flakyTest
104
+ readonly layer: <R2, E2>(
105
+ layer_: Layer.Layer<R2, E2>,
106
+ options?: LayerOptions
107
+ ) => LayerFn<R2, ExcludeTestServices>
108
+ readonly prop: Prop
109
+ }
110
+
111
+ export interface LayerOptions {
112
+ readonly memoMap?: Layer.MemoMap
113
+ readonly timeout?: Duration.DurationInput
114
+ readonly excludeTestServices?: boolean
115
+ }
116
+
117
+ export type LayerFn<R, ExcludeTestServices extends boolean = false> = {
118
+ (f: (it: MethodsNonLive<R, ExcludeTestServices>) => void): void
119
+ (name: string, f: (it: MethodsNonLive<R, ExcludeTestServices>) => void): void
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Test Environment
124
+ // ---------------------------------------------------------------------------
125
+
126
+ const TestEnv: Layer.Layer<any> = TestContext.TestContext.pipe(
127
+ Layer.provide(Logger.remove(Logger.defaultLogger))
128
+ )
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // runPromise — Bun-adapted Effect runner (no signal, no onTestFinished)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ const runPromise = <A, E>(effect: Effect.Effect<A, E, never>): Promise<A> =>
135
+ Effect.gen(function*() {
136
+ const exit = yield* Effect.exit(effect)
137
+ if (Exit.isSuccess(exit)) {
138
+ return exit.value
139
+ }
140
+ if (Cause.isInterruptedOnly(exit.cause)) {
141
+ throw new Error("All fibers interrupted without errors.")
142
+ }
143
+ const errors = Cause.prettyErrors(exit.cause)
144
+ for (let i = 1; i < errors.length; i++) {
145
+ yield* Effect.logError(errors[i])
146
+ }
147
+ throw errors[0]!
148
+ }).pipe(Effect.runPromise)
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // makeTester — creates a Tester<R> that maps each test body through mapEffect
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export const makeTester = <R>(
155
+ mapEffect: (self: Effect.Effect<any, any, R>) => Effect.Effect<any, any, never>,
156
+ testFn: typeof bunTest = bunTest
157
+ ): Tester<R> => {
158
+ const run = <A, E>(self: () => Effect.Effect<A, E, R>): Promise<void> =>
159
+ runPromise(pipe(Effect.suspend(self), mapEffect, Effect.asVoid))
160
+
161
+ const f: Test<R> = (name, self, timeout) =>
162
+ testFn(name, () => run(self), timeout)
163
+
164
+ const skip: Test<R> = (name, self, timeout) =>
165
+ testFn.skip(name, () => run(self), timeout)
166
+
167
+ const only: Test<R> = (name, self, timeout) =>
168
+ testFn.only(name, () => run(self), timeout)
169
+
170
+ const skipIf =
171
+ (condition: unknown): Test<R> =>
172
+ (name, self, timeout) =>
173
+ testFn.skipIf(condition as boolean)(name, () => run(self), timeout)
174
+
175
+ const ifCond =
176
+ (condition: unknown): Test<R> =>
177
+ (name, self, timeout) =>
178
+ testFn.if(condition as boolean)(name, () => run(self), timeout)
179
+
180
+ const each =
181
+ <T>(cases: ReadonlyArray<T>) =>
182
+ <A, E>(
183
+ name: string,
184
+ self: (args: T) => Effect.Effect<A, E, R>,
185
+ timeout?: number
186
+ ) =>
187
+ testFn.each(cases as T[])(
188
+ name,
189
+ (args: T) =>
190
+ runPromise(pipe(Effect.suspend(() => self(args)), mapEffect, Effect.asVoid)),
191
+ timeout
192
+ )
193
+
194
+ const failing: Test<R> = (name, self, timeout) =>
195
+ testFn.failing(name, () => run(self), timeout)
196
+
197
+ const propFn: PropEffect<R> = (name, arbitraries, self, timeout) => {
198
+ const fcOptions =
199
+ typeof timeout === "object" ? timeout?.fastCheck : undefined
200
+ const timeoutMs = typeof timeout === "number" ? timeout : undefined
201
+ testFn(
202
+ name,
203
+ async () => {
204
+ await fc.assert(
205
+ fc.asyncProperty(
206
+ ...(arbitraries as unknown as [fc.Arbitrary<any>]),
207
+ (...args: any[]) =>
208
+ runPromise(
209
+ pipe(
210
+ Effect.suspend(() => (self as any)(...args)) as Effect.Effect<any, any, R>,
211
+ mapEffect,
212
+ Effect.asVoid
213
+ )
214
+ )
215
+ ),
216
+ fcOptions
217
+ )
218
+ },
219
+ timeoutMs
220
+ )
221
+ }
222
+
223
+ return Object.assign(f, {
224
+ skip,
225
+ only,
226
+ skipIf,
227
+ if: ifCond,
228
+ runIf: ifCond,
229
+ each,
230
+ failing,
231
+ fails: failing,
232
+ prop: propFn
233
+ }) as Tester<R>
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Preconfigured testers
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /** Run effects with test services (TestClock, TestConsole, etc.). Not auto-scoped. */
241
+ export const effect: Tester<never> = makeTester((e) =>
242
+ Effect.provide(e, TestEnv)
243
+ )
244
+
245
+ /** Run effects with test services, auto-scoped. */
246
+ export const scoped: Tester<Scope.Scope> = makeTester((e) =>
247
+ e.pipe(Effect.scoped, Effect.provide(TestEnv))
248
+ )
249
+
250
+ /** Run effects live (no test services), not auto-scoped. */
251
+ export const live: Tester<never> = makeTester((e) => e as any)
252
+
253
+ /** Run effects live (no test services), auto-scoped. */
254
+ export const scopedLive: Tester<Scope.Scope> = makeTester((e) => Effect.scoped(e))
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // flakyTest — retry an effect up to 10 times within a timeout
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export const flakyTest = <A, E, R>(
261
+ self: Effect.Effect<A, E, R>,
262
+ timeout: Duration.DurationInput = Duration.seconds(30)
263
+ ): Effect.Effect<A, never, R> =>
264
+ pipe(
265
+ Effect.catchAllDefect(self, (defect) => Effect.fail(defect as any)),
266
+ Effect.retry(
267
+ pipe(
268
+ Schedule.recurs(10),
269
+ Schedule.compose(Schedule.elapsed),
270
+ Schedule.whileOutput(Duration.lessThanOrEqualTo(timeout))
271
+ )
272
+ ),
273
+ Effect.orDie
274
+ ) as any
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // prop — non-Effect property-based test
278
+ // ---------------------------------------------------------------------------
279
+
280
+ export const prop: Prop = (name, arbitraries, self, timeout) => {
281
+ const fcOptions =
282
+ typeof timeout === "object" ? timeout?.fastCheck : undefined
283
+ const timeoutMs = typeof timeout === "number" ? timeout : undefined
284
+ bunTest(
285
+ name,
286
+ async () => {
287
+ await fc.assert(
288
+ fc.asyncProperty(
289
+ ...(arbitraries as unknown as [fc.Arbitrary<any>]),
290
+ (...args: any[]) => (self as any)(...args)
291
+ ),
292
+ fcOptions
293
+ )
294
+ },
295
+ timeoutMs
296
+ )
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // layer — shared Layer across tests with beforeAll/afterAll lifecycle
301
+ // ---------------------------------------------------------------------------
302
+
303
+ export const layer = <R, E, const ExcludeTestServices extends boolean = false>(
304
+ layer_: Layer.Layer<R, E>,
305
+ options?: {
306
+ readonly memoMap?: Layer.MemoMap
307
+ readonly timeout?: Duration.DurationInput
308
+ readonly excludeTestServices?: ExcludeTestServices
309
+ }
310
+ ): LayerFn<R, ExcludeTestServices> => {
311
+
312
+ return ((...args: any[]) => {
313
+ const excludeTestServices = options?.excludeTestServices ?? false
314
+ const withTestEnv: Layer.Layer<any, any> = excludeTestServices
315
+ ? (layer_ as any)
316
+ : Layer.provideMerge(layer_ as any, TestEnv as any)
317
+ const memoMap = options?.memoMap ?? Effect.runSync(Layer.makeMemoMap)
318
+ const scope = Effect.runSync(Scope.make())
319
+ const timeout = options?.timeout
320
+ ? Duration.toMillis(options.timeout)
321
+ : undefined
322
+
323
+ const runtimeEffect = pipe(
324
+ Layer.toRuntimeWithMemoMap(withTestEnv, memoMap),
325
+ Scope.extend(scope),
326
+ Effect.orDie,
327
+ Effect.cached,
328
+ Effect.runSync
329
+ )
330
+
331
+ const effectTester = makeTester<any>(
332
+ (e) =>
333
+ Effect.flatMap(runtimeEffect, (runtime) =>
334
+ Effect.provide(e, runtime)
335
+ ) as any
336
+ )
337
+
338
+ const scopedTester = makeTester<any>(
339
+ (e) =>
340
+ Effect.flatMap(runtimeEffect, (runtime) =>
341
+ e.pipe(Effect.scoped, Effect.provide(runtime))
342
+ ) as any
343
+ )
344
+
345
+ const makeIt = (): MethodsNonLive<R, ExcludeTestServices> =>
346
+ ({
347
+ effect: effectTester,
348
+ scoped: scopedTester,
349
+ flakyTest,
350
+ prop,
351
+ layer: <R2, E2>(
352
+ nestedLayer: Layer.Layer<R2, E2>,
353
+ nestedOptions?: LayerOptions
354
+ ) =>
355
+ layer(
356
+ Layer.provideMerge(nestedLayer as any, withTestEnv) as any,
357
+ {
358
+ ...nestedOptions,
359
+ memoMap,
360
+ excludeTestServices: true
361
+ } as any
362
+ )
363
+ }) as any
364
+
365
+ if (args.length === 1) {
366
+ beforeAll(
367
+ () => runPromise(Effect.asVoid(runtimeEffect)),
368
+ timeout
369
+ )
370
+ afterAll(
371
+ () => runPromise(Scope.close(scope, Exit.void)),
372
+ timeout
373
+ )
374
+ return args[0](makeIt())
375
+ }
376
+
377
+ return describe(args[0] as string, () => {
378
+ beforeAll(
379
+ () => runPromise(Effect.asVoid(runtimeEffect)),
380
+ timeout
381
+ )
382
+ afterAll(
383
+ () => runPromise(Scope.close(scope, Exit.void)),
384
+ timeout
385
+ )
386
+ return args[1](makeIt())
387
+ })
388
+ }) as any
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // makeMethods — compose the full `Methods` object
393
+ // ---------------------------------------------------------------------------
394
+
395
+ export const makeMethods = (): Methods => ({
396
+ effect,
397
+ scoped,
398
+ live,
399
+ scopedLive,
400
+ flakyTest,
401
+ layer,
402
+ prop
403
+ })
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // addEqualityTesters — register Effect Equal-aware matchers via expect.extend
407
+ // ---------------------------------------------------------------------------
408
+
409
+ export const addEqualityTesters = (): void => {
410
+ expect.extend({
411
+ toEqualEffect(received: unknown, expected: unknown) {
412
+ if (!Equal.isEqual(received) || !Equal.isEqual(expected)) {
413
+ const pass = Object.is(received, expected)
414
+ return {
415
+ pass,
416
+ message: () =>
417
+ pass
418
+ ? `Expected values to not be equal`
419
+ : `Expected values to be equal`
420
+ }
421
+ }
422
+ const pass = Equal.equals(received, expected)
423
+ return {
424
+ pass,
425
+ message: () =>
426
+ pass
427
+ ? `Expected Effect values to not be equal (via Equal.equals)`
428
+ : `Expected Effect values to be equal (via Equal.equals), but they differ`
429
+ }
430
+ }
431
+ })
432
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,199 @@
1
+ import { expect } from "bun:test"
2
+ import * as Equal from "effect/Equal"
3
+ import { Option, Either, Exit, Cause } from "effect"
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Primitive assertions
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export function fail(message: string): never {
10
+ throw new Error(message)
11
+ }
12
+
13
+ export function deepStrictEqual<A>(actual: A, expected: A, message?: string): void {
14
+ expect(actual).toStrictEqual(expected)
15
+ if (message) {
16
+ // Message is informational — the assertion above throws on failure
17
+ }
18
+ }
19
+
20
+ export function strictEqual<A>(actual: A, expected: A, message?: string): void {
21
+ expect(actual).toBe(expected)
22
+ }
23
+
24
+ export function notDeepStrictEqual<A>(actual: A, expected: A, message?: string): void {
25
+ expect(actual).not.toStrictEqual(expected)
26
+ }
27
+
28
+ /** Compare using Effect's `Equal.equals` for structural equality. */
29
+ export function assertEquals<A>(actual: A, expected: A, message?: string): void {
30
+ if (!Equal.equals(actual, expected)) {
31
+ const msg = message ?? `Expected values to be equal (via Equal.equals)`
32
+ throw new Error(
33
+ `${msg}\n actual: ${JSON.stringify(actual)}\n expected: ${JSON.stringify(expected)}`
34
+ )
35
+ }
36
+ }
37
+
38
+ export function doesNotThrow(thunk: () => void, message?: string): void {
39
+ try {
40
+ thunk()
41
+ } catch (e) {
42
+ throw new Error(message ?? `Expected function not to throw, but it threw: ${e}`)
43
+ }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Boolean assertions
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export function assertTrue(self: unknown, message?: string): asserts self {
51
+ expect(self).toBeTruthy()
52
+ }
53
+
54
+ export function assertFalse(self: boolean, message?: string): void {
55
+ expect(self).toBeFalsy()
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Type assertions
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export function assertInstanceOf<C extends abstract new (...args: any[]) => any>(
63
+ value: unknown,
64
+ constructor: C,
65
+ message?: string
66
+ ): asserts value is InstanceType<C> {
67
+ expect(value).toBeInstanceOf(constructor)
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // String assertions
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export function assertInclude(actual: string | undefined, expected: string): void {
75
+ expect(actual).toBeDefined()
76
+ expect(actual).toInclude(expected)
77
+ }
78
+
79
+ export function assertMatch(actual: string, regexp: RegExp): void {
80
+ expect(actual).toMatch(regexp)
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Throw assertions
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export function throws(
88
+ thunk: () => void,
89
+ error?: Error | ((u: unknown) => void)
90
+ ): void {
91
+ if (typeof error === "function") {
92
+ try {
93
+ thunk()
94
+ throw new Error("Expected function to throw")
95
+ } catch (e) {
96
+ error(e)
97
+ }
98
+ } else if (error) {
99
+ expect(thunk).toThrow(error.message)
100
+ } else {
101
+ expect(thunk).toThrow()
102
+ }
103
+ }
104
+
105
+ export async function throwsAsync(
106
+ thunk: () => Promise<void>,
107
+ error?: Error | ((u: unknown) => void)
108
+ ): Promise<void> {
109
+ if (typeof error === "function") {
110
+ try {
111
+ await thunk()
112
+ throw new Error("Expected async function to throw")
113
+ } catch (e) {
114
+ error(e)
115
+ }
116
+ } else {
117
+ try {
118
+ await thunk()
119
+ throw new Error("Expected async function to throw")
120
+ } catch (e) {
121
+ if (error && e instanceof Error) {
122
+ expect(e.message).toInclude(error.message)
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Option assertions
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export function assertNone<A>(
133
+ option: Option.Option<A>
134
+ ): asserts option is Option.None<never> {
135
+ expect(Option.isNone(option)).toBe(true)
136
+ }
137
+
138
+ export function assertSome<A>(
139
+ option: Option.Option<A>,
140
+ expected: A
141
+ ): asserts option is Option.Some<A> {
142
+ expect(Option.isSome(option)).toBe(true)
143
+ if (Option.isSome(option)) {
144
+ assertEquals(option.value, expected)
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Either assertions
150
+ // ---------------------------------------------------------------------------
151
+
152
+ export function assertRight<A, E>(
153
+ either: Either.Either<A, E>,
154
+ expected: A
155
+ ): asserts either is Either.Right<E, A> {
156
+ expect(Either.isRight(either)).toBe(true)
157
+ if (Either.isRight(either)) {
158
+ assertEquals(either.right, expected)
159
+ }
160
+ }
161
+
162
+ export function assertLeft<A, E>(
163
+ either: Either.Either<A, E>,
164
+ expected: E
165
+ ): asserts either is Either.Left<E, A> {
166
+ expect(Either.isLeft(either)).toBe(true)
167
+ if (Either.isLeft(either)) {
168
+ assertEquals(either.left as any, expected as any)
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Exit assertions
174
+ // ---------------------------------------------------------------------------
175
+
176
+ export function assertExitSuccess<A, E>(
177
+ exit: Exit.Exit<A, E>,
178
+ expected: A
179
+ ): asserts exit is Exit.Success<A, never> {
180
+ expect(Exit.isSuccess(exit)).toBe(true)
181
+ if (Exit.isSuccess(exit)) {
182
+ assertEquals(exit.value, expected)
183
+ }
184
+ }
185
+
186
+ export function assertExitFailure<A, E>(
187
+ exit: Exit.Exit<A, E>,
188
+ expected: Cause.Cause<E>
189
+ ): asserts exit is Exit.Failure<never, E> {
190
+ expect(Exit.isFailure(exit)).toBe(true)
191
+ if (Exit.isFailure(exit)) {
192
+ assertEquals(exit.cause as any, expected as any)
193
+ }
194
+ }
195
+
196
+ /** @deprecated Use assertExitSuccess. Alias. */
197
+ export const assertSuccess = assertExitSuccess
198
+ /** @deprecated Use assertExitFailure. Alias. */
199
+ export const assertFailure = assertExitFailure