Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/effect-bdd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@evryg/effect-bdd": patch
---

Add `@evryg/effect-bdd`, a runner-agnostic, inspectable Behaviour-Driven-Development DSL for Effect.

Author `Given / When / Then` scenarios with a type-safe, phase-typed builder that accumulates a named-key context, enforces step dependencies, and feeds each outcome (`then` / `thenFails` / `thenDies`) the correct payload type. A scenario is reified data, so the same value can be run under any test runner (assertion failures surface as a typed `ScenarioError`, never via a runner's `expect`), rendered to Gherkin, listed as steps, or projected to a serializable document. Reusable domain vocabularies can be built on top of the `given` / `when` / `assertion` combinators, with `feature` / `filterByTags` to group and select suites.

A `harness` layer adds reified, re-interpretable actions and a port-spy/observation harness on top of the core: `dispatcher` turns a tagged command value into a `When`, `probe` captures a service's calls via `Layer.mock`, and `makeHarness` bundles preconditions, probes and a dispatcher into a per-domain builder where `when` takes a bare command, `then` receives the recorded calls as an injected observation object, and `run` auto-provides the probe layers — all as a pure composition over the (untouched) core.
1 change: 1 addition & 0 deletions packages/bdd/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @evryg/effect-bdd
21 changes: 21 additions & 0 deletions packages/bdd/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Evryg SAS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
151 changes: 151 additions & 0 deletions packages/bdd/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# @evryg/effect-bdd

A runner-agnostic, inspectable Behaviour-Driven-Development DSL for [Effect](https://effect.website).

Author `Given / When / Then` scenarios with a type-safe, phase-typed builder. A
scenario is **data**, not an `Effect` — so the same value can be **run** under
any test runner, **rendered** to Gherkin, or **inspected** as structure.

- **Runner-agnostic** — the core imports no test runner. A scenario's terminal
is a plain `Effect`; run it with `@effect/vitest`, `node:test`, `bun:test`, or
`Effect.runPromise`. Assertion failures surface as a typed `ScenarioError`, not
via a runner's `expect`.
- **Inspectable** — a scenario is a reified term you can render to Gherkin, list
the steps of, or project to a serializable document for `.feature` export.
- **Type-safe & combinator-based** — the phase-typed builder accumulates a
named-key context, enforces step dependencies, and feeds each outcome
(`then` / `thenFails` / `thenDies`) the correct payload type. Build reusable
domain vocabularies on top of the `given` / `when` / `assertion` combinators.

## Installation

```sh
pnpm add @evryg/effect-bdd effect
```

`effect` is a peer dependency.

## Quick start

```ts
import { run, scenario } from "@evryg/effect-bdd"
import { Effect } from "effect"

const addingAnItem = scenario("adding to an empty cart", { tags: ["cart"] })
.given("an empty cart", () => Effect.succeed({ items: [] as ReadonlyArray<string> }))
.when("the user adds a book", (context) => Effect.succeed([...context.items, "book"]))
.then("the cart holds one item", (items) => {
if (items.length !== 1) throw new Error(`expected 1 item, got ${items.length}`)
})
```

`addingAnItem` is a value. Run it under any runner:

```ts
import { it } from "@effect/vitest"

it.effect("adding to an empty cart", () => run(addingAnItem))

// …or without any runner at all:
Effect.runPromise(run(addingAnItem))
```

## Outcomes

Choose the expected outcome with the matching method; each receives the right
payload type:

```ts
scenario("removing from an empty cart")
.when("the user removes an item", () => Effect.fail({ code: "EMPTY" } as const))
.thenFails("it reports the cart is empty", (error) => {
if (error.code !== "EMPTY") throw new Error("unexpected error")
})

scenario("dividing by zero")
.when("the calculator divides by zero", () => Effect.die(new Error("boom")))
.thenDies("it dies with the cause", (defect) => {
if (!(defect instanceof Error)) throw new Error("expected an Error defect")
})
```

## Inspecting a scenario

```ts
import { steps, toDocument, toGherkin } from "@evryg/effect-bdd"

toGherkin(addingAnItem)
// @cart
// Scenario: adding to an empty cart
// Given an empty cart
// When the user adds a book
// Then the cart holds one item

steps(addingAnItem) // [{ keyword: "Given", text: … }, …]
toDocument(addingAnItem) // a serializable ScenarioDocument (validate/encode via its Schema)
```

## Features

Group scenarios and select a suite by tag:

```ts
import { feature, filterByTags, selectByTags } from "@evryg/effect-bdd"

const storefront = feature("storefront", [addingAnItem /*, … */])
const smoke = selectByTags(storefront, ["smoke"])
const cartScenarios = filterByTags(storefront.scenarios, ["cart"])
```

## Domain vocabularies (core mode)

The real power is building higher-level, constrained combinators on top of
`given` / `when` / `assertion`, so scenarios read as ubiquitous language and
misuse is a compile-time error. See [`examples/core/Cart.ts`](./examples/core/Cart.ts)
and [`examples/core/Counter.ts`](./examples/core/Counter.ts).

```ts
scenario("adding an item to an empty cart")
.given(aCart().empty())
.when(user.adds(item("book", 10)))
.then(theCart.holds(1))
.and(theCart.costs(10))
```

Because `user.adds` declares it *needs* a cart in its context, chaining it before
`aCart()` does not type-check.

## Harness mode — reified actions, probes & a preset

The `harness` layer adds what a per-domain Algebra + Interpreter buys, without
giving up the runner-agnostic, inspectable core:

- **`dispatcher`** turns a *reified command* (a tagged value) into a `When` via a
per-tag handler map. The command is data, so the same command type can be
driven by multiple dispatchers (run vs. dry-run vs. model).
- **`probe`** wraps a service in a `Layer.mock` with a call-log, capturing which
methods the action invoked (indirect outputs).
- **`makeHarness`** bundles initial preconditions + probes + a dispatcher into a
domain-specialized builder: `when` takes a **bare command**, `then` receives
the recorded calls as an **injected observation object**, and `run`
**auto-provides** the probe layers.

See [`examples/harness/Cart.ts`](./examples/harness/Cart.ts) and
[`examples/harness/Counter.ts`](./examples/harness/Counter.ts) — the same two
domains as the core examples, expressed via the preset.

```ts
const cart = makeHarness({ initial, probes: { ledger }, dispatch })

const adding = cart.scenario("adding an item")
.when(addItem("book")) // a bare, reified command
.then("the addition is recorded", ({ ledger }) => {
if (ledger[0] !== "+book") throw new Error("not recorded")
})

cart.run(adding) // probe layers auto-provided; still `toGherkin`-able
```

The harness is a thin composition over the core, so a harness scenario is still a
plain `Scenario` you can `toGherkin`, and assertion failures still surface as a
typed `ScenarioError` under any runner.
8 changes: 8 additions & 0 deletions packages/bdd/docgen.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "../../node_modules/@effect/docgen/schema.json",
"exclude": [
"src/internal/**/*.ts",
"src/**/*.test.ts",
"src/**/*.test-d.ts"
]
}
110 changes: 110 additions & 0 deletions packages/bdd/examples/core/Cart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Core-mode example: building a higher-level, domain-specific BDD vocabulary on
* top of the core combinators (compare with `../harness/Cart.ts`, the same
* domain via the preset harness).
*
* The framework ships the meta-combinators (`scenario`, `given`, `when`,
* `assertion`). A domain builds *constrained* combinators on top — `aCart()`,
* `user.adds(...)`, `theCart.holds(...)` — each resolving to a typed
* `Given`/`When`/`Assertion`. Specialised type safety falls out of the types:
* `user.adds` declares it *needs* a cart in context, so it cannot be used before
* `aCart()` is given, and `theCart.holds` only accepts the cart the action
* produced. Scenarios then read as ubiquitous language.
*
* Run with: `npx tsx packages/bdd/examples/core/Cart.ts`
*/
import { assertion, given, run, scenario, toGherkin, when } from "@evryg/effect-bdd"
import type { Assertion, Given, When } from "@evryg/effect-bdd"
import { Effect } from "effect"

// ── Domain model ──
interface Item {
readonly name: string
readonly price: number
}
interface Cart {
readonly items: ReadonlyArray<Item>
}
interface HasCart {
readonly cart: Cart
}

const item = (name: string, price: number): Item => ({ name, price })

// ── A constrained `given` sub-builder ──
// `aCart()` exposes only the operations that yield a valid cart precondition.
interface CartGiven {
readonly empty: () => Given<{}, HasCart, never>
readonly containing: (...items: ReadonlyArray<Item>) => Given<{}, HasCart, never>
}

const aCart = (): CartGiven => ({
empty: () => given("an empty cart", () => Effect.succeed({ cart: { items: [] as ReadonlyArray<Item> } })),
containing: (...items) =>
given(`a cart containing ${items.map((entry) => entry.name).join(", ")}`, () => Effect.succeed({ cart: { items } }))
})

// ── `when` combinators that REQUIRE a cart in context ──
const user = {
adds: (added: Item): When<HasCart, Cart, never, never> =>
when(
`the user adds ${added.name}`,
(context: HasCart) => Effect.succeed({ items: [...context.cart.items, added] })
),
removes: (removed: Item): When<HasCart, Cart, "item-not-in-cart", never> =>
when(
`the user removes ${removed.name}`,
(context: HasCart) =>
context.cart.items.some((entry) => entry.name === removed.name)
? Effect.succeed({ items: context.cart.items.filter((entry) => entry.name !== removed.name) })
: Effect.fail("item-not-in-cart" as const)
)
}

// ── `assertion` combinators specialised to the cart the action returns ──
const theCart = {
holds: (count: number): Assertion<Cart, never> =>
assertion(`the cart holds ${count} item(s)`, (cart: Cart) => {
if (cart.items.length !== count) {
throw new Error(`expected ${count} item(s) but the cart holds ${cart.items.length}`)
}
}),
costs: (total: number): Assertion<Cart, never> =>
assertion(`the cart costs ${total}`, (cart: Cart) => {
const sum = cart.items.reduce((accumulator, entry) => accumulator + entry.price, 0)
if (sum !== total) throw new Error(`expected total ${total} but it is ${sum}`)
})
}

// ── Scenarios authored as ubiquitous language ──
export const addingAnItem = scenario("adding an item to an empty cart", { tags: ["cart"] })
.given(aCart().empty())
.when(user.adds(item("book", 10)))
.then(theCart.holds(1))
.and(theCart.costs(10))

export const removingAMissingItem = scenario("removing an item that is not in the cart", { tags: ["cart"] })
.given(aCart().containing(item("book", 10)))
.when(user.removes(item("pen", 2)))
.thenFails("it reports the item is not in the cart", (error) => {
if (error !== "item-not-in-cart") throw new Error(`unexpected error: ${error}`)
})

// Specialised type safety: `user.adds` declares `Needs = HasCart`, so authoring
// it before a cart is given is a compile-time error — the combinator simply
// cannot be chained there:
//
// scenario("no cart").when(user.adds(item("book", 10)))
// // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 'cart' is missing in type {}

const demo = Effect.gen(function*() {
yield* run(addingAnItem)
yield* run(removingAMissingItem)
yield* Effect.sync(() => {
console.log(toGherkin(addingAnItem))
console.log()
console.log(toGherkin(removingAMissingItem))
})
})

void Effect.runPromise(demo)
66 changes: 66 additions & 0 deletions packages/bdd/examples/core/Counter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Core-mode example: a second domain vocabulary, showing a failure outcome and
* grouping a suite into a `Feature` that can be filtered by tag (compare with
* `../harness/Counter.ts`, the same domain via the preset harness).
*
* As in `./Cart.ts`, the `when` combinators declare they need a counter in
* context, and the failure channel is part of the combinator's type — so
* `.thenFails` receives the precise `"below-zero"` error.
*
* Run with: `npx tsx packages/bdd/examples/core/Counter.ts`
*/
import { assertion, feature, filterByTags, given, run, scenario, toGherkin, when } from "@evryg/effect-bdd"
import type { Assertion, Given, When } from "@evryg/effect-bdd"
import { Effect } from "effect"

interface HasCount {
readonly count: number
}

const aCounterAt = (start: number): Given<{}, HasCount, never> =>
given(`a counter at ${start}`, () => Effect.succeed({ count: start }))

const incrementedBy = (amount: number): When<HasCount, number, never, never> =>
when(`incremented by ${amount}`, (context: HasCount) => Effect.succeed(context.count + amount))

const decrementedBy = (amount: number): When<HasCount, number, "below-zero", never> =>
when(
`decremented by ${amount}`,
(context: HasCount) =>
context.count - amount < 0 ? Effect.fail("below-zero" as const) : Effect.succeed(context.count - amount)
)

const theCount = {
reads: (expected: number): Assertion<number, never> =>
assertion(`the count reads ${expected}`, (count: number) => {
if (count !== expected) throw new Error(`expected ${expected} but read ${count}`)
})
}

const incrementing = scenario("incrementing", { tags: ["happy"] })
.given(aCounterAt(0))
.when(incrementedBy(3))
.then(theCount.reads(3))

const rejectingUnderflow = scenario("decrementing below zero is rejected", { tags: ["edge"] })
.given(aCounterAt(1))
.when(decrementedBy(5))
.thenFails("it refuses to go below zero", (error) => {
if (error !== "below-zero") throw new Error(`unexpected error: ${error}`)
})

export const counting = feature("counting", [incrementing, rejectingUnderflow])

const demo = Effect.gen(function*() {
yield* run(incrementing)
yield* run(rejectingUnderflow)
const edgeCases = filterByTags(counting.scenarios, ["edge"])
yield* Effect.sync(() => {
console.log(`Feature: ${counting.name}`)
for (const spec of edgeCases) {
console.log(toGherkin(spec))
}
})
})

void Effect.runPromise(demo)
Loading
Loading