Skip to content

Commit

Permalink
Add vitest layer api (#3689)
Browse files Browse the repository at this point in the history
  • Loading branch information
tim-smart authored Sep 26, 2024
1 parent ce8b810 commit 4d91f41
Show file tree
Hide file tree
Showing 4 changed files with 255 additions and 12 deletions.
44 changes: 44 additions & 0 deletions .changeset/dull-laws-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@effect/vitest": patch
---

add layer api to `@effect/vitest`

This allows you to share a `Layer` between multiple tests, optionally wrapping
the tests in a `describe` block.

```ts
import { expect, layer } from "@effect/vitest"
import { Context, Effect, Layer } from "effect"

class Foo extends Context.Tag("Foo")<Foo, "foo">() {
static Live = Layer.succeed(Foo, "foo")
}

class Bar extends Context.Tag("Bar")<Bar, "bar">() {
static Live = Layer.effect(
Bar,
Effect.map(Foo, () => "bar" as const)
)
}

layer(Foo.Live)("layer", (it) => {
it.effect("adds context", () =>
Effect.gen(function* () {
const foo = yield* Foo
expect(foo).toEqual("foo")
})
)

it.layer(Bar.Live)("nested", (it) => {
it.effect("adds context", () =>
Effect.gen(function* () {
const foo = yield* Foo
const bar = yield* Bar
expect(foo).toEqual("foo")
expect(bar).toEqual("bar")
})
)
})
})
```
74 changes: 72 additions & 2 deletions packages/vitest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import type * as Duration from "effect/Duration"
import type * as Effect from "effect/Effect"
import type * as Layer from "effect/Layer"
import type * as Scope from "effect/Scope"
import type * as TestServices from "effect/TestServices"
import * as V from "vitest"
Expand Down Expand Up @@ -47,7 +48,26 @@ export namespace Vitest {
cases: ReadonlyArray<T>
) => <A, E>(name: string, self: TestFunction<A, E, R, Array<T>>, timeout?: number | V.TestOptions) => void
}

/**
* @since 1.0.0
*/
export interface Methods<R = never> extends API {
readonly effect: Vitest.Tester<TestServices.TestServices | R>
readonly live: Vitest.Tester<R>
readonly flakyTest: <A, E, R2>(
self: Effect.Effect<A, E, R2>,
timeout?: Duration.DurationInput
) => Effect.Effect<A, never, R2>
readonly scoped: Vitest.Tester<TestServices.TestServices | Scope.Scope | R>
readonly scopedLive: Vitest.Tester<Scope.Scope | R>
readonly layer: <R2, E>(layer: Layer.Layer<R2, E, R>) => {
(f: (it: Vitest.Methods<R | R2>) => void): void
(name: string, f: (it: Vitest.Methods<R | R2>) => void): void
}
}
}

/**
* @since 1.0.0
*/
Expand All @@ -73,6 +93,56 @@ export const live: Vitest.Tester<never> = internal.live
*/
export const scopedLive: Vitest.Tester<Scope.Scope> = internal.scopedLive

/**
* Share a `Layer` between multiple tests, optionally wrapping
* the tests in a `describe` block if a name is provided.
*
* @since 1.0.0
*
* ```ts
* import { expect, layer } from "@effect/vitest"
* import { Context, Effect, Layer } from "effect"
*
* class Foo extends Context.Tag("Foo")<Foo, "foo">() {
* static Live = Layer.succeed(Foo, "foo")
* }
*
* class Bar extends Context.Tag("Bar")<Bar, "bar">() {
* static Live = Layer.effect(
* Bar,
* Effect.map(Foo, () => "bar" as const)
* )
* }
*
* layer(Foo.Live)("layer", (it) => {
* it.effect("adds context", () =>
* Effect.gen(function* () {
* const foo = yield* Foo
* expect(foo).toEqual("foo")
* })
* )
*
* it.layer(Bar.Live)("nested", (it) => {
* it.effect("adds context", () =>
* Effect.gen(function* () {
* const foo = yield* Foo
* const bar = yield* Bar
* expect(foo).toEqual("foo")
* expect(bar).toEqual("bar")
* })
* )
* })
* })
* ```
*/
export const layer: <R, E>(
layer_: Layer.Layer<R, E>,
memoMap?: Layer.MemoMap
) => {
(f: (it: Vitest.Methods<R>) => void): void
(name: string, f: (it: Vitest.Methods<R>) => void): void
} = internal.layer

/**
* @since 1.0.0
*/
Expand All @@ -82,12 +152,12 @@ export const flakyTest: <A, E, R>(
) => Effect.Effect<A, never, R> = internal.flakyTest

/** @ignored */
const methods = { effect, live, flakyTest, scoped, scopedLive } as const
const methods = { effect, live, flakyTest, scoped, scopedLive, layer } as const

/**
* @since 1.0.0
*/
export const it: API & typeof methods = Object.assign(V.it, methods)
export const it: Vitest.Methods = Object.assign(V.it, methods)

/**
* @since 1.0.0
Expand Down
82 changes: 74 additions & 8 deletions packages/vitest/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,28 @@ import * as Fiber from "effect/Fiber"
import { flow, identity, pipe } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Logger from "effect/Logger"
import * as Runtime from "effect/Runtime"
import * as Schedule from "effect/Schedule"
import type * as Scope from "effect/Scope"
import * as Scope from "effect/Scope"
import * as TestEnvironment from "effect/TestContext"
import type * as TestServices from "effect/TestServices"
import * as Utils from "effect/Utils"
import * as V from "vitest"
import type * as Vitest from "./index.js"

/** @internal */
const runTest = (ctx: Vitest.TaskContext) => <E, A>(effect: Effect.Effect<A, E>) =>
const runPromise = (ctx?: Vitest.TaskContext) => <E, A>(effect: Effect.Effect<A, E>) =>
Effect.gen(function*() {
const exitFiber = yield* Effect.fork(Effect.exit(effect))
const runtime = yield* Effect.runtime()

ctx.onTestFinished(() =>
ctx?.onTestFinished(() =>
Fiber.interrupt(exitFiber).pipe(
Effect.asVoid,
Runtime.runPromise(runtime)
Effect.runPromise
)
)

const exit = yield* Fiber.join(exitFiber)
if (Exit.isSuccess(exit)) {
return () => {}
return () => exit.value
} else {
const errors = Cause.prettyErrors(exit.cause)
for (let i = 1; i < errors.length; i++) {
Expand All @@ -47,6 +44,10 @@ const runTest = (ctx: Vitest.TaskContext) => <E, A>(effect: Effect.Effect<A, E>)
}
}).pipe(Effect.runPromise).then((f) => f())

/** @internal */
const runTest = (ctx?: Vitest.TaskContext) => <E, A>(effect: Effect.Effect<A, E>) =>
runPromise(ctx)(Effect.asVoid(effect))

/** @internal */
const TestEnv = TestEnvironment.TestContext.pipe(
Layer.provide(Logger.remove(Logger.defaultLogger))
Expand Down Expand Up @@ -98,6 +99,71 @@ const makeTester = <R>(
return Object.assign(f, { skip, skipIf, runIf, only, each })
}

/** @internal */
export const layer = <R, E>(layer_: Layer.Layer<R, E>, memoMap?: Layer.MemoMap): {
(f: (it: Vitest.Vitest.Methods<R>) => void): void
(name: string, f: (it: Vitest.Vitest.Methods<R>) => void): void
} =>
(
...args: [name: string, f: (it: Vitest.Vitest.Methods<R>) => void] | [f: (it: Vitest.Vitest.Methods<R>) => void]
) => {
memoMap = memoMap ?? Effect.runSync(Layer.makeMemoMap)
const scope = Effect.runSync(Scope.make())
const runtimeEffect = Layer.toRuntimeWithMemoMap(layer_, memoMap).pipe(
Scope.extend(scope),
Effect.orDie,
Effect.cached,
Effect.runSync
)

const it: Vitest.Vitest.Methods<R> = Object.assign(V.it, {
effect: makeTester<TestServices.TestServices | R>((effect) =>
Effect.flatMap(runtimeEffect, (runtime) =>
effect.pipe(
Effect.provide(runtime),
Effect.provide(TestEnv)
))
),
scoped: makeTester<TestServices.TestServices | Scope.Scope | R>((effect) =>
Effect.flatMap(runtimeEffect, (runtime) =>
effect.pipe(
Effect.scoped,
Effect.provide(runtime),
Effect.provide(TestEnv)
))
),
live: makeTester<R>((effect) =>
Effect.flatMap(
runtimeEffect,
(runtime) => Effect.provide(effect, runtime)
)
),
scopedLive: makeTester<Scope.Scope | R>((effect) =>
Effect.flatMap(runtimeEffect, (runtime) =>
effect.pipe(
Effect.scoped,
Effect.provide(runtime)
))
),
flakyTest,
layer<R2, E2>(nestedLayer: Layer.Layer<R2, E2, R>) {
return layer(Layer.provideMerge(nestedLayer, layer_), memoMap)
}
})

if (args.length === 1) {
V.beforeAll(() => runPromise()(Effect.asVoid(runtimeEffect)))
V.afterAll(() => runPromise()(Scope.close(scope, Exit.void)))
return args[0](it)
}

return V.describe(args[0], () => {
V.beforeAll(() => runPromise()(Effect.asVoid(runtimeEffect)))
V.afterAll(() => runPromise()(Scope.close(scope, Exit.void)))
return args[1](it)
})
}

/** @internal */
export const effect = makeTester<TestServices.TestServices>(Effect.provide(TestEnv))

Expand Down
67 changes: 65 additions & 2 deletions packages/vitest/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, it } from "@effect/vitest"
import { Effect } from "effect"
import { afterAll, describe, expect, it, layer } from "@effect/vitest"
import { Context, Effect, Layer } from "effect"

it.live(
"live %s",
Expand Down Expand Up @@ -85,3 +85,66 @@ it.scopedLive("interrupts on timeout", (ctx) =>
)
yield* Effect.sleep(1000)
}), { timeout: 100, fails: true })

class Foo extends Context.Tag("Foo")<Foo, "foo">() {
static Live = Layer.succeed(Foo, "foo")
}

class Bar extends Context.Tag("Bar")<Bar, "bar">() {
static Live = Layer.effect(Bar, Effect.map(Foo, () => "bar" as const))
}

layer(Foo.Live)("layer", (it) => {
it.effect("adds context", () =>
Effect.gen(function*() {
const foo = yield* Foo
expect(foo).toEqual("foo")
}))

it.layer(Bar.Live)("nested", (it) => {
it.effect("adds context", () =>
Effect.gen(function*() {
const foo = yield* Foo
const bar = yield* Bar
expect(foo).toEqual("foo")
expect(bar).toEqual("bar")
}))
})

it.layer(Bar.Live)((it) => {
it.effect("without name", () =>
Effect.gen(function*() {
const foo = yield* Foo
const bar = yield* Bar
expect(foo).toEqual("foo")
expect(bar).toEqual("bar")
}))
})

describe("release", () => {
let released = false
afterAll(() => {
expect(released).toEqual(true)
})

class Scoped extends Context.Tag("Scoped")<Scoped, "scoped">() {
static Live = Layer.scoped(
Scoped,
Effect.acquireRelease(
Effect.succeed("scoped" as const),
() => Effect.sync(() => released = true)
)
)
}

it.layer(Scoped.Live)((it) => {
it.effect("adds context", () =>
Effect.gen(function*() {
const foo = yield* Foo
const scoped = yield* Scoped
expect(foo).toEqual("foo")
expect(scoped).toEqual("scoped")
}))
})
})
})

0 comments on commit 4d91f41

Please sign in to comment.