Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/bdd.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Type declarations for the runner-agnostic BDD layer.

export type World = Record<string, any>;

export interface Step {
kind: "Given" | "When" | "Then" | "And";
text: string;
run: (world: World) => unknown | Promise<unknown>;
}

export interface ScenarioOptions {
world?: World;
log?: (line: string) => void;
}

export declare function given(text: string, run: (world: World) => unknown | Promise<unknown>): Step;
export declare function when(text: string, run: (world: World) => unknown | Promise<unknown>): Step;
export declare function then(text: string, run: (world: World) => unknown | Promise<unknown>): Step;
export declare function and(text: string, run: (world: World) => unknown | Promise<unknown>): Step;

/** Turn a list of steps into an async function for your runner's test(). */
export declare function scenario(steps: Step[], options?: ScenarioOptions): () => Promise<World>;

/** Build a labelled list of scenarios from a feature definition. */
export declare function feature(
name: string,
scenarios: Record<string, Step[]>,
options?: ScenarioOptions
): Array<{ name: string; run: () => Promise<World> }>;
42 changes: 42 additions & 0 deletions src/bdd.js
Original file line number Diff line number Diff line change
@@ -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),
}));
}
64 changes: 64 additions & 0 deletions test/unit/bdd.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
});
Loading