gherkin-node-test
v0.4.0
Published
The smallest honest Gherkin runner and feature-file linter — zero dependencies, native on node:test, bun:test, and Deno, built for agent-driven BDD.
Maintainers
Readme
gherkin-node-test
The smallest honest Gherkin runner. Zero dependencies, no build step, no
CLI — it turns .feature files into real tests on your runtime's built-in
runner: node:test under Node,
bun:test natively under Bun, and node:test
under Deno (whose
node:test bridges to the native Deno.test runner) — and it treats every
silence as a bug. One file, ~850 lines, small enough to read in one sitting or
to vendor outright. The same file doubles as a feature-file linter for
projects whose runner is something else — see
the linter role.
npm i -D gherkin-node-test # or just copy index.js into your repoWhy another BDD tool
There are excellent Gherkin runners already — cucumber-js if you want the full standard and its platform, vitest-cucumber if you live in Vitest. This one exists for a different reason, and if the reason doesn't resonate, use those instead.
This runner came out of an experiment in agent-driven development with strict BDD: a workflow where a human writes and owns the Gherkin feature files, and coding agents write essentially all of the implementation. In that workflow the feature files aren't documentation — they're the control layer. They're the one artifact the human actually reads, audits, and carries between implementations. Everything underneath is regenerable.
That inverts what matters in a test harness. When no human reads every line of the code, the harness is the only witness — and the failure mode that kills you is not a crash. It's a false green: a suite that says "all your acceptance criteria hold" when some of them were never checked. Crashes get fixed; silences compound.
False greens have specific, boring causes. Each one is a design decision here:
| How suites lie | What this runner does about it |
|---|---|
| The parser half-understands a construct and silently drops steps or table cells | Unsupported syntax is a hard error with file:line — doc strings, Rule:, ragged tables, a table row missing its closing \|, all of it. Never a best-effort parse. |
| A scenario with zero bound steps "passes" | Unbound scenarios register as node:test TODO — and TODO is reported as passing, so the high-level runner fails the suite on any unbound step unless the feature is explicitly listed as work-in-progress. Rewording one step can't silently un-test a feature. |
| A step matches two definitions and one silently wins | Ambiguity is asserted against per feature, at suite start, for every step. |
| Step definitions collide across the suite's global namespace | There is no global namespace: each feature gets its own registry. An agent editing one feature structurally cannot break another's bindings. |
| A scaffolded step stub passes vacuously | Missing-step errors include a paste-ready definition whose body throws pending. You cannot paste your way to a false green. |
| A failing assertion leaks the temp dir / process it was about to clean up | world.defer(fn) runs cleanup LIFO even when a step fails. |
| A typo'd @skip tag is silently inert | Misplaced tags, dangling tags, and near-miss tags (@Skip, @ONLY) are loud errors — @Skip would run a scenario meant to be skipped, @Only would dodge the loud @only rejection. |
| A committed @only silently narrows what CI runs | @only never focuses anything — it's rejected as a failing test on every runtime. Focus is a per-run CLI flag (--test-name-pattern / -t / --filter), which can't be committed into the suite. |
The same properties turn out to be exactly what a coding agent needs, because
agents act on error output. A located file:line error, a failure message
containing the snippet that fixes it, a ratchet that converts silent decay into
a red test — these close the agent's write→run→fix loop through the test runner
itself. None of this was designed "for AI"; it was designed for a human who
couldn't personally re-read the implementation, which is rapidly becoming
everyone's situation.
And because the runner compiles scenarios into the runtime's own test runner,
there is no second toolchain: one command (node --test, bun test, or
deno test) runs unit tests and acceptance criteria together, with watch mode,
coverage, and CI reporters inherited from the runtime itself.
Quick start
features/
counter.feature
test/
features.test.js
steps/counter.steps.js# features/counter.feature
Feature: Counter
Scenario: increment once
Given a counter at 0
When I add 5
Then the counter is 5// test/features.test.js
const path = require('node:path');
const { runFeatures } = require('gherkin-node-test');
runFeatures(path.join(__dirname, '..', 'features'), {
// feature basename → its step definer
'counter': require('./steps/counter.steps'),
}, { wip: [] }); // basenames still bootstrapping (unbound steps allowed as TODO)// test/steps/counter.steps.js
const assert = require('node:assert');
module.exports = (reg) => {
reg.define(/^a counter at (\d+)$/, (w, n) => { w.count = Number(n); });
reg.define(/^I add (\d+)$/, (w, n) => { w.count += Number(n); });
reg.define(/^the counter is (\d+)$/, (w, n) => assert.strictEqual(w.count, Number(n)));
};node --test # or: bun test # or: deno test --allow-readEach scenario becomes one test named Feature :: Scenario. A fresh
world object is created per scenario; Background steps run before each one.
Alongside the scenarios, runFeatures registers the guard tests described
above (ambiguity, unbound steps, orphaned definer keys).
If a step is missing, the guard failure hands you the definition:
✖ counter :: step definitions are complete and unambiguous
unbound steps would register as TODO (passing); bind them or add 'counter' to wip:
// I add 5
reg.define(/^I add (\d+)$/, (w, p1) => {
throw new Error('pending: implement this step');
});The binding ratchet
That guard failure is half of the design's central mechanism. The other half
is the wip list — together they form a ratchet: binding coverage (the
fraction of your feature files' steps wired to executable code) can move
forward freely, and can never slip backward silently.
The decay path the ratchet closes is induced by normal editing, not by bad
tests: reword one step in a .feature file and its regex no longer matches;
the scenario becomes unbound; node:test registers it as TODO — which is
reported as passing — and a feature you believed was tested is now tested
by nothing, with no signal emitted. In a workflow where feature files are
edited constantly (by you or by an agent), that path would be exercised
weekly.
So the guard fails the suite on any unbound step, and the wip list is the
one sanctioned exception:
- Bootstrapping: add a new feature's basename to
wipand bind steps one at a time. Its unbound scenarios still register — visibly, as TODO — they just don't fail the suite. Honest green, with the debt on display. - The click: when the last step binds, remove the feature from
wip. That's the pawl dropping into the next tooth — from this commit forward the feature cannot silently lose coverage again. - Backward motion is loud in exactly two ways, both reviewable diffs:
the suite goes red (with a paste-ready, throwing definition per missing
step), or someone re-adds the feature to
wip— a one-line, grep-able confession in the test file. There is no third path.
wip is therefore a debt register: grep wip test/features.test.js
tells you exactly which features are not yet fully enforced. It relaxes
only unbound-ness — ambiguity stays a hard error even for wip features
("not fully bound yet" never means "allowed to be ambiguous").
Two companion rules seal the ratchet's other entrances: the orphan-definer
guard (renaming a .feature file can't silently strand its steps), and
skip-still-binds (@skip means "don't run", never "don't bind" — otherwise
a tag would be a hole in the ratchet).
Runs on Bun and Deno — natively
Bun. Under Bun this runner registers scenarios directly on
bun:test, not through Bun's node:test
compatibility shim (which Bun's own docs mark partial, and which deliberately
drops the only: option). Same feature files, same guards, same ratchet —
bun test instead of node --test. Verified against Bun 1.3.14; the CI bun
lane runs this repo's entire suite, subprocess proofs included, to keep this
section true.
Deno. Under Deno the same code runs through node:test — and that is
native here: Deno's node:test is not a partial reimplementation but a
faithful polyfill that bridges registrations to the built-in Deno.test
runner. So there is no Deno-specific registration path to maintain —
deno test --allow-read runs the same feature files and guards. (Deno needs
read permission for the .feature files, and nothing else: the library reads
no env vars and spawns nothing. Add --allow-run --allow-env only when
running this repo's own suite, whose test/runner.test.js spawns
subprocesses.) Verified against Deno 2.9.2; a CI deno lane keeps this true.
One behavior on all three runtimes
The guarantees are identical everywhere, by construction: where the runtimes' own test runners would diverge dangerously, this runner rejects the construct loudly instead of behaving three different ways. Two rules exist solely for that:
@only is rejected — it never focuses anything. The three runners' focus
semantics are irreconcilable: Node keeps only: inert unless you also pass
--test-only; Bun and Deno focus the tagged scenario's whole file on every
run, no flag — and Deno exits 0 doing it, so a committed @only would
silently narrow a CI run to the focused scenarios while staying green. That
is exactly the false green this runner exists to prevent, so the tag maps to
nothing on any runtime: it registers a failing test
(<feature basename> :: @only is not supported) whose message names the fix. The
rejection is additive — every scenario, tagged or not, still registers and
runs, so the rejection never narrows the suite it polices. To actually focus
one scenario, use your runner's per-run filter — a CLI argument that cannot
be committed into the suite:
node --test --test-name-pattern "increment"
bun test -t "increment"
deno test --allow-read --filter "increment"(Two cautions on runner-native focus, both runner behavior rather than this
library's: Node's --test-only flag is useless here — nothing is ever
only-marked, so it filters out every test and reports a green zero-test run.
And under Bun and Deno, a test.only(...) of your own in the same file as
runFeatures focus-filters this library's scenarios and guards out,
because their focus is file-wide. A feature test file that contains exactly
one runFeatures call and nothing else — the shape the one-call rule below
pushes you toward anyway — has neither problem.)
One runFeatures call per test file. Deno silently swallows a top-level
throw once an earlier test() has been registered in the same file — which is
exactly where a second call's load-time errors (a non-function definer, an
unparseable feature file) would vanish with exit 0. So a second call in the
same test file is refused, uniformly: it registers a single failing test
naming the rule and does nothing else, and the first call is untouched. One
call per file keeps every load error ahead of every registration — loud on all
three runtimes. (The refusal is registered, not thrown, for the same
swallow reason.)
What still differs — the honest list, pinned by tests:
@todoscenario bodies: Node executes them (failures shown, never gate); Bun executes them only underbun test --todo(where a passing todo fails the run); Deno never executes them (todo folds into "ignored"). The safety-relevant part is uniform:@todonever gates the suite anywhere, and its steps must still bind.- Reporter output (test counts, summary format) is each runtime's own.
- Permissions: only Deno needs a flag (
--allow-read).
One footnote: bun test and deno test run those runtimes' own test
runners; bun run test runs this package's test script (node --test).
Both work — they're just different runtimes.
N-version verification
Because the feature files are language-neutral and strictly separated from
step code, they support a workflow that used to be priced out of reach:
independent implementations of the same spec, diffed against each other.
Classic N-version programming meant paying two teams; with coding agents, a
second implementation of a pure kernel costs one prompt. The features are
the shared contract — this runner and its Rust sibling
gherkin-cargo-test parse the
same dialect, so one .feature suite can drive both implementations
verbatim.
The mechanics, beyond running the same scenarios against both:
- Drive both implementations with identical generated inputs — a deterministic PRNG implementable bit-for-bit in both languages (e.g. mulberry32: integer ops that JS and Rust/Go/C agree on exactly), so both sides see the same doubles in the same order.
- Compare a checksum over every output (not just pass/fail). Agreement to full float precision is the strongest correctness evidence available to someone who cannot read the code; disagreement localizes a bug to one side before any user ever sees it.
- A behavioral divergence that no scenario catches is a spec gap with two witnesses — feed it back into the feature file.
When it's worth it: pure, deterministic kernels — parsers, numeric and financial code, codecs, business rules — where subtle bugs (boundary conditions, float behavior) would otherwise be silent; any port, where the old implementation verifies the new one for free; anywhere the human auditing the system reads only the features. When it isn't: I/O-heavy glue and UI code, whose behavior is the environment rather than a function of its inputs.
Proven in practice: a TypeScript signal-processing kernel and its agent-written Rust port, bound to md5-identical feature files, matched to six decimal places over thousands of PRNG-generated inputs — on the first comparison.
Supported grammar
| Construct | Notes |
|---|---|
| Feature: | exactly one per file, required |
| Background: | optional, at most one, must precede every Scenario |
| Scenario: | free-text title |
| Scenario Outline: | requires exactly one Examples: table |
| Examples: | a header row then ≥1 data row, \|-delimited |
| <placeholder> | substituted from the Examples columns — in step text and step data tables; every <name> must match a column |
| Steps | Given When Then And But *, followed by step text |
| Step data tables | \| rows after a step attach to it; the step function receives a DataTable as its last argument |
| Tags | @skip → skipped (steps must still bind); @todo → registered, never gates; @only → rejected loudly (focus with your runner's per-run filter instead); the three are mutually exclusive; tags on Feature: apply to all its scenarios; any other tag is carried on scenario.tags with no runtime effect |
| # comment | ignored anywhere |
| Feature narrative | the As a… / I want… / So that… prose block is ignored |
Table cells honor the Gherkin escapes \| (literal pipe), \\ (literal
backslash) and \n (newline); a backslash before any other character is
literal, so cells like C:\Temp or Cmd+\ need no escaping.
Tag semantics: @skip never executes the scenario (but its steps must still be
bound — skip means "don't run", never "don't bind"); @todo doesn't gate the
run (Node executes the body and reports failures; Bun executes it only under
--todo; Deno never executes it); @only is rejected as a failing test on
every runtime — see One behavior on all three runtimes
for why, and for the per-runner focus alternatives. The three are mutually
exclusive — the runtimes disagree on what a combination would mean, so a
combination is a parse error.
Step matching and DataTable
Steps are matched by RegExp or exact string — capture groups become step
arguments. There are no Cucumber Expressions ({int}, {string}); write a
regex.
A step with a data table receives a DataTable as its last argument,
API-compatible with cucumber-js so step code (and muscle memory) ports both
ways:
Given these users
| name | role |
| ada | admin |reg.define(/^these users$/, (w, table) => {
table.raw(); // [['name','role'],['ada','admin']] (defensive copy)
table.rows(); // rows minus the header
table.hashes(); // [{ name: 'ada', role: 'admin' }]
table.rowsHash(); // two-column table → { key: value } map
table.transpose(); // columns become rows → new DataTable
});Scenario-scoped cleanup: world.defer(fn)
Cleanup runs after the scenario in reverse (LIFO) order — including when a
step failed. The step failure, if any, outranks cleanup errors; if the steps
passed, the first cleanup error fails the scenario. (defer is a reserved key
on the world object.)
reg.define(/^a scratch dir$/, (w) => {
w.dir = fs.mkdtempSync(prefix);
w.defer(() => fs.rmSync(w.dir, { recursive: true, force: true }));
});Deliberately unsupported — and rejected loudly
The design rule: parse the supported subset correctly; reject everything else
with a file:line error; never parse a feature file vacuously. Each of these
throws GherkinSyntaxError with the offending line number:
| Rejected | Why it's rejected, not ignored |
|---|---|
| Doc strings (""" / ```) | would be mis-read line-by-line as steps |
| Multiple Examples: per Outline | the 2nd header row would corrupt the expansion |
| Examples: with no data rows / no header | would expand to zero (vacuous) scenarios |
| Ragged table rows (Examples or step tables) | column misalignment would pass silently |
| A table row missing its closing \| | the trailing cell would be silently dropped |
| A table row with no preceding step | the data would silently belong to nothing |
| Unknown <placeholder> | almost always a typo; would leak <name> into a step |
| A Scenario/Scenario Outline with no steps | would run zero assertions and pass vacuously |
| A step after its Examples: table | malformed ordering; the step would mis-attach |
| Tags anywhere but immediately before Feature: / Scenario: / Scenario Outline: | a mis-placed @skip would silently not skip |
| A near-miss semantic tag (@Skip, @SKIP, @Only, …) | would be silently inert — @Skip would run a scenario meant to be skipped, @Only would dodge the loud @only rejection |
| Combined semantic tags (@skip @only on one scenario) | node:test takes them as options with its own precedence, bun:test as mutually exclusive methods — a combination can't mean the same thing on both, so it must not mean anything silently |
| Rule: (Gherkin 6) | grouping would be silently flattened |
| A step before any Scenario/Background | would be silently discarded |
| A 2nd Feature: / Background:, or Background: after a Scenario | ambiguous scope |
Two non-features by design, with no dedicated error: Cucumber Expressions (write a regex) and i18n (English keywords only — a non-English keyword reads as narrative; if that empties a scenario, the no-steps guard fires, so it still can't pass vacuously).
The linter role — under someone else's runner
Everything above assumes this is your runner. It doesn't have to be:
lintFeature(text, filename?) exposes the same loud dialect gate, plus
deterministic spec lints, as a pure function — text in, findings out. No
filesystem, no environment, no test registration, so it behaves identically on
Node, Bun, and Deno, and directory walking stays in your code.
The use case: your project runs its features on another executor
(vitest-cucumber, cucumber-js) but you want its .feature files held to this
dialect — say, because they must stay portable to a second implementation, or
because you want the spec-quality floor without changing runners. One guard
test in your existing suite does it:
// feature-guards.test.ts (vitest example)
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { test, expect } from 'vitest';
import { lintFeature } from 'gherkin-node-test';
test('every feature file is in-dialect and non-vacuous', () => {
const files = readdirSync('tests', { recursive: true, encoding: 'utf8' })
.filter((f) => f.endsWith('.feature'))
.map((f) => join('tests', f));
const problems = files.flatMap((file) =>
lintFeature(readFileSync(file, 'utf8'), file)
.map((f) => `${file}:${f.line}: [${f.rule}] ${f.message}`));
expect(problems).toEqual([]);
});Findings carry { rule, severity, line, message }:
| Rule | Severity | Fires on |
|---|---|---|
| dialect | error | anything the rejection table rejects — the exact GherkinSyntaxError, as a finding (the parser stops at the first violation, so it arrives alone) |
| no-then | warn | a scenario whose steps never resolve to Then — runs code, asserts nothing (And/But/* inherit the preceding primary keyword, across a Background) |
| vague-then | warn | a Then-resolved step containing works · correctly · properly · as expected · handles · appropriate — words that assert nothing checkable |
| single-row-outline | warn | a Scenario Outline with one Examples row — a scenario with extra ceremony, and usually a missing case |
Outline findings are reported once per source construct, not once per expanded row — except a vagueness introduced by a placeholder substitution, which is reported for exactly the rows that produce it.
Severity is descriptive, not policy. dialect is an error because the runner
would refuse the file; the lints warn because adopting them on an existing
suite needs a debt register, and that register — a wip-style allowlist,
filtering by rule — belongs in your guard test, where it's grep-able, not
hidden in a config file.
The two-parser rule. When the linter and the executor are different parsers, the executor's interpretation of a feature file is the authoritative one — the linter gates dialect membership and spec quality, never meaning. Keep drafting inside the conservative intersection (no escapes in table cells, no exotic placeholder tricks) and the two parsers have nothing to disagree about; pin the version of this package, and the pinned version is your de-facto dialect version.
When not to use this
- You want tag-expression filtering, parallel workers, retries, HTML living-documentation reports, or attachments → cucumber-js. That's a platform; this is a file.
- You're on Vitest/Jest → vitest-cucumber / jest-cucumber integrate
with the runner you already have. (You can still point
lintFeatureat the same.featurefiles — see the linter role.) - You need the full Gherkin grammar (doc strings,
Rule:, i18n) → @cucumber/gherkin is the real parser.
The niche here is exactly: Gherkin on the runtime's built-in runner —
node:test, bun:test, or Deno — zero dependencies, loud by construction.
API
| Export | Purpose |
|---|---|
| runFeatures(dir, definers, { wip }?) | high-level runner: discover every .feature, scoped registries, guard tests; one call per test file (a second call is refused loudly) |
| parseFeature(text, filename?) | parse → { feature, background, scenarios, outlines }; throws GherkinSyntaxError |
| lintFeature(text, filename?) | linter: dialect gate + spec lints as { rule, severity, line, message }[] — pure text-in/findings-out, for use under another runner |
| StepRegistry | .define(pattern, fn) / .find(text) |
| executeSteps(steps, registry, world?) | run a flat step list against a shared world (installs world.defer) |
| runFeature(parsed, registry) | register one runner test per scenario (@skip/@todo mapped; @only → failing test; unbound → TODO) |
| runFeatureFile(file, registry) | read + parse + run a single .feature file |
| DataTable | cucumber-compatible step table: raw / rows / hashes / rowsHash / transpose |
| buildSnippet(text) | paste-ready step definition for an unbound step (body throws) |
| GherkinSyntaxError | thrown on unsupported/malformed syntax; carries .line |
Provenance
Extracted from ccr, where it runs ~15 feature
files / ~180 scenarios as the acceptance layer of a shipping CLI — written and
hardened by the agent-driven BDD workflow it advocates, including adversarial
review of its own guards (the closing-pipe check exists because that review
found the naive parser silently dropping a cell). The self-test suite
(test/harness.test.js) includes a rejection test for every guard above, a
self-proving @skip scenario whose only step throws, and an eval of a
generated snippet proving it's valid JS that matches its own step and fails
until implemented.
MIT © Bing Ho
