diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f5d8e..032b814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **BDD layer** (`two-go/bdd`): runner-agnostic `given` / `when` / `then` / + `and` plus `scenario(steps)` and `feature(...)`. `scenario` returns an async + function for your runner's `test()`, with steps sharing a `world`. Does not + import any runner, so it works with node:test, Jest, Vitest, and Mocha. + ## [1.0.0] - 2026-06-02 First stable release. The HTTP client, assertions, utilities, and the AI and MCP diff --git a/README.md b/README.md index 4d76898..e930091 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ you're used to. - [Schema validation and inference](#schema-validation-and-inference) - [Debugging with curl and logging](#debugging-with-curl-and-logging) - [Running tests](#running-tests) +- [BDD style](#bdd-style) - [Importing from OpenAPI or Postman](#importing-from-openapi-or-postman) - [Generating tests with AI](#generating-tests-with-ai) - [TypeScript](#typescript) @@ -188,6 +189,8 @@ subpath ships its own types. | `two-go/infer-schema` | schema inference | | `two-go/importers` | OpenAPI and Postman importers | | `two-go/ai` | optional AI layer (provider plus test generation) | +| `two-go/mcp` | the MCP server | +| `two-go/bdd` | runner-agnostic given/when/then helpers | ## Building a request @@ -580,6 +583,34 @@ suite("smoke", ({ test }) => { const { passed, failed } = await run(); ``` +## BDD style + +If you like given/when/then, `two-go/bdd` gives you a small, runner-agnostic +layer. `scenario(steps)` returns an async function you pass to your runner's +`test()`. Steps share a `world` object, and two-go's assertions decide pass or +fail. It does not import any runner, so it works with node:test, Jest, Vitest, +and Mocha. + +```js +import { test } from "node:test"; +import { go } from "two-go"; +import { scenario, given, when, then, and } from "two-go/bdd"; + +const api = go("https://api.example.com"); + +test("creating a user", scenario([ + given("a valid payload", (w) => { w.payload = { name: "Ada", email: "ada@example.com" }; }), + when("the user is created", async (w) => { w.res = await api.post("/users").json(w.payload); }), + then("the response is 201", (w) => w.res.expectStatus(201)), + and("the body echoes the name", (w) => w.res.expectJson("name", "Ada")), +])); +``` + +A `when` stashes the response on `world`, a `then` asserts on it. For a runnable +end to end suite (a shop with login, cart, checkout, and more) see the +`ecommerce-bdd` example in +[two-go-examples](https://github.com/tugkanboz/two-go-examples). + ## Importing from OpenAPI or Postman If you already have an OpenAPI document or a Postman collection, you can turn it diff --git a/package.json b/package.json index 2752602..d299592 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "./infer-schema": { "types": "./src/infer-schema.d.ts", "default": "./src/infer-schema.js" }, "./importers": { "types": "./src/importers/index.d.ts", "default": "./src/importers/index.js" }, "./ai": { "types": "./src/ai/index.d.ts", "default": "./src/ai/index.js" }, - "./mcp": { "types": "./src/mcp/server.d.ts", "default": "./src/mcp/server.js" } + "./mcp": { "types": "./src/mcp/server.d.ts", "default": "./src/mcp/server.js" }, + "./bdd": { "types": "./src/bdd.d.ts", "default": "./src/bdd.js" } }, "bin": { "two-go": "bin/twogo.js", diff --git a/src/bdd.d.ts b/src/bdd.d.ts new file mode 100644 index 0000000..3b5c14a --- /dev/null +++ b/src/bdd.d.ts @@ -0,0 +1,29 @@ +// Type declarations for the runner-agnostic BDD layer. + +export type World = Record; + +export interface Step { + kind: "Given" | "When" | "Then" | "And"; + text: string; + run: (world: World) => unknown | Promise; +} + +export interface ScenarioOptions { + world?: World; + log?: (line: string) => void; +} + +export declare function given(text: string, run: (world: World) => unknown | Promise): Step; +export declare function when(text: string, run: (world: World) => unknown | Promise): Step; +export declare function then(text: string, run: (world: World) => unknown | Promise): Step; +export declare function and(text: string, run: (world: World) => unknown | Promise): Step; + +/** Turn a list of steps into an async function for your runner's test(). */ +export declare function scenario(steps: Step[], options?: ScenarioOptions): () => Promise; + +/** Build a labelled list of scenarios from a feature definition. */ +export declare function feature( + name: string, + scenarios: Record, + options?: ScenarioOptions +): Array<{ name: string; run: () => Promise }>; diff --git a/src/bdd.js b/src/bdd.js new file mode 100644 index 0000000..b04f4cc --- /dev/null +++ b/src/bdd.js @@ -0,0 +1,42 @@ +// A tiny BDD layer, runner agnostic. given/when/then/and build step objects +// that share a `world`. scenario(steps) returns an async function you hand to +// your test runner's test() (node:test, Jest, Vitest, Mocha). This module does +// not import any runner, and two-go's throwing assertions decide pass or fail. + +const makeStep = (kind) => (text, run) => ({ kind, text, run }); + +/** A "Given" step: set up state on the world. */ +export const given = makeStep("Given"); +/** A "When" step: perform the action, usually stashing a response on the world. */ +export const when = makeStep("When"); +/** A "Then" step: assert on what the When produced. */ +export const then = makeStep("Then"); +/** An "And" step: continue the previous Given/When/Then. */ +export const and = makeStep("And"); + +// Turn a list of steps into an async function. Steps run in order and share a +// `world` object. options.world seeds the world; options.log(line) receives +// each step's "Given ..." description if you want to print it. +export function scenario(steps, options = {}) { + return async function runScenario() { + const world = options.world ? { ...options.world } : {}; + for (const step of steps) { + if (typeof options.log === "function") options.log(`${step.kind} ${step.text}`); + await step.run(world); + } + return world; + }; +} + +// Build a labelled list of scenarios from a feature definition, so you can loop +// and register each one with your runner: +// +// for (const s of feature("Login", { "valid creds": [given(...), ...] })) { +// test(s.name, s.run); +// } +export function feature(name, scenarios, options = {}) { + return Object.entries(scenarios).map(([title, steps]) => ({ + name: `${name}: ${title}`, + run: scenario(steps, options), + })); +} diff --git a/test/unit/bdd.test.mjs b/test/unit/bdd.test.mjs new file mode 100644 index 0000000..35af0f1 --- /dev/null +++ b/test/unit/bdd.test.mjs @@ -0,0 +1,64 @@ +// Unit tests for the runner-agnostic BDD layer. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { scenario, feature, given, when, then, and } from "../../src/bdd.js"; + +test("step builders carry a kind, text, and run", () => { + const g = given("a thing", () => {}); + assert.equal(g.kind, "Given"); + assert.equal(g.text, "a thing"); + assert.equal(typeof g.run, "function"); + assert.equal(when("x", () => {}).kind, "When"); + assert.equal(then("x", () => {}).kind, "Then"); + assert.equal(and("x", () => {}).kind, "And"); +}); + +test("scenario runs steps in order sharing one world", async () => { + const order = []; + const run = scenario([ + given("seed", (w) => { w.n = 1; order.push("g"); }), + when("increment", (w) => { w.n += 1; order.push("w"); }), + then("assert", (w) => { assert.equal(w.n, 2); order.push("t"); }), + ]); + const world = await run(); + assert.deepEqual(order, ["g", "w", "t"]); + assert.equal(world.n, 2); +}); + +test("scenario awaits async steps", async () => { + const run = scenario([ + when("async work", async (w) => { w.value = await Promise.resolve(42); }), + then("value is set", (w) => assert.equal(w.value, 42)), + ]); + await run(); +}); + +test("a throwing step rejects the scenario", async () => { + const run = scenario([ + then("fails", () => { throw new Error("boom"); }), + ]); + await assert.rejects(run, /boom/); +}); + +test("scenario can seed the world and log step lines", async () => { + const lines = []; + const run = scenario( + [given("uses seed", (w) => assert.equal(w.base, 10))], + { world: { base: 10 }, log: (line) => lines.push(line) } + ); + await run(); + assert.deepEqual(lines, ["Given uses seed"]); +}); + +test("feature builds a labelled, runnable list", async () => { + const built = feature("Math", { + "adds": [given("a", (w) => { w.a = 2; }), then("ok", (w) => assert.equal(w.a, 2))], + "subtracts": [then("ok", () => {})], + }); + assert.equal(built.length, 2); + assert.equal(built[0].name, "Math: adds"); + assert.equal(built[1].name, "Math: subtracts"); + await built[0].run(); + await built[1].run(); +});