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