BDD with bddHarness
Write your acceptance criteria as Gherkin and run them against a real in-process app — every step dispatches through the actual runtime, not a mock. Nwire runs the scenarios through @amiceli/vitest-cucumber, and the bddHarness({ buildApp }) helper from @nwire/test-kit wires the harness into each scenario for you.
(It also absorbs a lifecycle quirk: BeforeEachScenario fires in the scenario's beforeAll, after the Background describe runs — so booting the app by hand in a step file lands at the wrong time. The helper handles that ordering so you don't have to.)
Install
pnpm add -D @nwire/test-kit @amiceli/vitest-cucumber vitest@amiceli/vitest-cucumber is a peer dep of test-kit's BDD helpers.
The step file pattern
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
import { expect } from "vitest";
import { bddHarness } from "@nwire/test-kit";
import { buildApp } from "../../app";
import { placeOrder } from "shop";
const feature = await loadFeature("./features/order-placement.feature");
describeFeature(feature, ({ Scenario, Background, AfterEachScenario }) => {
const ctx = bddHarness({
buildApp,
defaultUser: { id: "alice", roles: ["customer"] },
});
AfterEachScenario(() => ctx.reset());
Background(({ Given }) => {
Given("the platform is up", async () => {
await ctx.boot();
});
});
Scenario("Alice places an order", ({ When, Then }) => {
When("she places an order for {string}", async (_ctx, sku: string) => {
await ctx.scoped().dispatch(placeOrder, { orderId: "o-1", sku });
});
Then("the order is recorded", async () => {
expect(
ctx.harness!.telemetry.count(
"event.published",
(e) => (e.event as { eventName?: string }).eventName === "shop.order-placed",
),
).toBeGreaterThan(0);
});
});
});The bddHarness API
const ctx = bddHarness({ buildApp, defaultUser?, tenantPrefix? });| Member | When to call | What it does |
|---|---|---|
ctx.boot() | At the head of every step | Lazy-init the harness if not already booted. Idempotent within a scenario. |
ctx.scoped(overrides?) | When dispatching/querying | Returns an asUser-pinned scoped harness. Tenant + user are taken from ctx.user / ctx.tenant. |
ctx.user | Background/Scenario steps | Swap the pinned user for a step ("Dan is logged in as admin"). |
ctx.reset() | AfterEachScenario | Tear down the harness between scenarios. |
ctx.harness | Anywhere after boot() | Direct access to the underlying Harness for telemetry, idle-wait, etc. |
ctx.tenant | Read-only | Per-scenario tenant string ("t-1", "t-2", …). Isolates state when needed. |
ctx.boot() is the trick — call it as the first line of every Background step and every first Scenario step. The lazy-init makes the Background and Scenario steps wire to the same harness regardless of vitest-cucumber's hook ordering.
Step-text reuse caveat
vitest-cucumber binds registrations to feature steps by text. If a feature uses the same step pattern twice in one scope (Background or Scenario), each occurrence binds to the first matching registration — the second occurrence reports as "missing". Give each occurrence unique wording in the .feature file:
# Will fail — both bind to the first "a station {string}…" registration
Background:
Given a station "stn_a" exists
Given a station "stn_b" exists
# Works — unique wording per occurrence
Background:
Given a station "stn_a" exists
Given another station "stn_b" existsRunning
Point vitest at your step files. The example layout uses *.steps.ts:
// vite.config.ts
{
test: {
include: [
"**/*.{test,spec}.?(c|m)[jt]s?(x)",
"**/*.steps.?(c|m)[jt]s?(x)",
],
},
}// package.json
{
"scripts": {
"test:bdd": "vitest run apps/main/__tests__/steps"
}
}Real-world reference
The station-management example carries four full Gherkin suites (station-crud.feature, wash-recording.feature, wash-webhook.feature, idempotency-and-flow.feature) running through the in-process harness, plus a real Koa + endpoint stack per-scenario for the HMAC-signed webhook gate. 173 scenarios, all through bddHarness.
See: examples/station-management/apps/main/__tests__/steps/*.steps.ts.
See also
- Testing — unit + integration tests via
harness({app}).