gherkin-node-test
v0.11.0
Published
The smallest honest Gherkin runner and feature-file linter — zero dependencies, native on node:test, bun:test, and Deno, with a vitest adapter, 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, ~2,200 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 repoNode >=22.17 (also runs on Bun and Deno). Support policy: the active and
maintenance LTS lines — Node 22 rides until its EOL, April 2027; floors move
only at those boundaries, in a versioned release.
Why 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 definition outlives every scenario that consumed it | Dead definitions are rejected as a failing test naming the pattern and its feature — the wip ratchet's dual (wip catches scenarios without definitions; this catches definitions without scenarios). Keep a spec-first definition by writing the scenario it serves and holding that in the wip register; otherwise deletion is the remedy. |
| A binding quietly discards what the sentence parameterizes | Every invocation checks produced against declared: captures and the data table against the signature's positional parameters, both directions refused, defaults refused on sight. The two escapes are visible ones — a non-capturing group (?:…) in the pattern, or rest-form (w, ...args) in the signature (exempt, and sighted by the companion lint). |
| 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. |
| A feature directory nobody points the runner at is green by absence | The opt-in run manifest writes down what ran — one sorted {file, title, status} row per scenario, only on a full run — so joining it against the feature-file tree exposes coverage that silently isn't running. |
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.
The workflow around the tool — the roles, the loop, and the review practices that keep an agent-built suite honest above the layer these guards can reach (auditing agent-written step code, coverage-gap interrogation) — is written down in docs/workflow.md.
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: [] }); // features (or scenarios) still bootstrapping — see the ratchet// 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');
});Writing the feature files: the /scope skill
The runner enforces a contract it does not help you write. This repo ships an
Agent Skills skill for the step before: /scope, a
structured scoping interview that turns a project idea into lint-clean
.feature files in this dialect, plus an explicit out-of-scope fence. You are
the visionary — you answer one question at a time and never touch the feature
files; the interview stays in behavior space and refuses to record anything
that can't be phrased as an observable outcome.
Two commands in Claude Code:
/plugin marketplace add bingh0/gherkin-node-test
/plugin install scope@gherkin-node-testIt is a plain SKILL.md, so it also loads in VS Code / GitHub Copilot by
copying one directory. Full instructions for both, plus verification and
troubleshooting: plugins/scope/README.md.
The skill versions on its own line — currently scope 4.1.1, grounded against gherkin-node-test 0.9.0. It probes the linter's behavior rather than trusting a version string, and refuses to certify output it could not check.
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").
Scenario-scoped wip
A basename holds a whole feature open — the right grain while
bootstrapping, but too coarse for a feature that is 10/12 bound with two
scenarios waiting on an interface that doesn't exist yet. Wip-ing the whole
feature would relax the ratchet for the ten bound scenarios too. So wip
entries come in a second shape:
runFeatures('features', definers, {
wip: [
'checkout', // whole feature still bootstrapping
{ feature: 'smoothing', // 10/12 bound: hold open ONLY these two,
scenarios: [ // by source title (an outline's title
'resumes after a gap', // covers every expanded row)
'streams the tail <mode>',
] },
],
});The named scenarios register as TODO exactly like whole-feature wip; every other scenario in the feature keeps the full can't-silently-lose-a-binding guarantee. Two properties keep the finer grain honest:
- Scenario wip means "expected-unbound", never "skip". A listed scenario whose steps all happen to be bound runs normally — this lever cannot suppress executable code. To pend it, leave its distinguishing step unbound; there always is one (the step touching the unbuilt interface).
- Both wip shapes are ratcheted against rot. An entry whose feature — or scenario — has become fully bound fails the suite until the entry is removed: an allowlist that could go stale silently would hold the ratchet open for nothing. Likewise an entry naming a feature or scenario that no longer exists fails loudly; renaming can't strand debt off the register.
Under a wip'd feature, the TODOs you see are exactly the ones the register declares — a reviewer can tell "intentionally pending" from "someone broke a binding" by grepping the entry.
Three companion rules seal the ratchet's other entrances: the orphan-definer
guard (renaming a .feature file can't silently strand its steps),
skip-still-binds (@skip means "don't run", never "don't bind" — otherwise
a tag would be a hole in the ratchet), and the unused-definition guard, the
ratchet's dual: a definition no scenario consumes is a registered failing
test naming the pattern and its feature. The pair holds the ledger taut from
both sides — the ratchet catches scenarios without definitions, the guard
catches definitions without scenarios; the guard carries no register of its
own, because the sanctioned way to keep a definition is to write the
scenario it serves and hold that in the wip register.
The run manifest
Everything above makes a run that happens honest. It cannot make a run
happen: a feature directory nobody points runFeatures at is green by
absence — spec present, enforcement quietly missing, invisible in any diff of
the feature files. Results can't expose that class, because the lie is that
no result exists. (The nearby misses are refused: a missing feature
directory, a path that isn't a directory, and a directory with no .feature
files each fail the run with a registered test naming the path and the fix —
never a raw stack, never a silently green zero-scenario run.) The fix for
the far miss is one written sentence per run: the runner writes down what
ran, so a reader can notice what didn't.
runFeatures('features', definers, {
manifest: 'features/run-manifest.ndjson', // opt-in, one per feature dir
});Every registered scenario becomes one NDJSON row — sorted, fixed key order:
{"file":"counter.feature","title":"increment once","status":"passed"}
{"file":"counter.feature","title":"resumes after a gap","status":"unbound"}
{"file":"counter.feature","title":"streams the tail live [1]","status":"skipped"}Commit the manifest. A tool (or a reviewer) joining it against the .feature
files in the tree sees the silent class structurally: a feature file with no
rows in any manifest is coverage that isn't running.
The format is a contract, held deliberately small — and the file explains
itself: every account opens with an in-band schema declaration,
{"run-manifest":1}, so a reader holding nothing but the file knows what
they have. The key names the format, not the tool: the Rust sibling
writes byte-identical accounts. The version bumps only on a change that
would mislead a version-1 reader (a changed row shape or sort order — not
a new status value).
- Rows are
{file, title, status}and nothing else. No timestamps, no durations — volatile fields would churn git for nothing. The manifest's bytes change only when results change; when comes from the commit that touched it. Identity is path + title (outline rows land individually,title [n]); there is no invented ID scheme.fileis recorded relative to the manifest file's own directory — the account must not change with the machine or checkout path that produced it, so an absolutedirargument (the robust idiom under vitest) never leaks machine paths into committed bytes, and downstream joins stay portable. - Statuses are runtime-independent.
passedandfailedcome from execution;skipped(@skip) andunbound(the wip register's grain) come from registration — those bodies run nowhere or inconsistently, and a status that varied by runtime would break bytes-follow-results.todocomes from execution too, safely: the @todo inversion runs its body on every runtime, so the row is deterministic —todomeans declared and still failing, and a stale @todo (one that passes) writesfailed, so a red run's account explains the red. The same run writes the same bytes on Node, Bun, Deno, vitest, and every OS (\normalizes to/). - A partial run never writes. The file is written exactly once, when
every registered scenario's outcome has been observed. A name-filtered
(
--test-name-pattern/-t/--filter), bailed, or crashed run leaves the previous manifest untouched rather than overwriting a full account with a partial one. - A re-run body is refused loudly. vitest's
retryandrepeatsre-run the same test body — and assign opposite verdicts to the same rerun sequence (fail-then-pass passes under retry, fails under repeats), which is indistinguishable from inside the body. Rather than silently pick one meaning, the second invocation fails the run with an error naming the fix (same doctrine as the@onlyrejection), and the manifest is not written. Deterministic suites never see this: retry only re-runs failures. Bun's--rerun-eachre-runs whole files and is naturally immune. - The runner stays stateless. The manifest is a reporter, not a memory: nothing is ever read back, nothing gates on it, registration is unchanged. Failure history, flakiness, change dating — all derivable by downstream tooling from the committed manifest's git history, none of it this library's business.
One manifest per runFeatures call — which the one-call rule makes one per
feature directory, and enforced in-process: a second call claiming an
already-claimed path is refused loudly (a registered failing test — its
scenarios still run; only its manifest is withheld), since two accounts
sharing one path would silently overwrite each other. A directory that
registers zero scenarios writes a zero-byte file — visibly empty is an
account; an absent file would read as "never ran". Point the path anywhere;
the directory must exist (the writer never mkdirs — a write failure is
loud, though a scenario's own failure outranks it). Under Deno, opting in
needs --allow-write=<that directory> on top of the usual --allow-read.
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. Opting into the run
manifest adds --allow-write=<its directory> — the one
write the library ever performs. Add
--allow-run --allow-env --allow-write=fixtures/.manifest-out only when
running this repo's own suite, whose test/runner.test.js spawns
subprocesses and whose manifest tests write.) 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.)
@todo is one behavior everywhere too — by inversion (0.9.0). The
runtimes' own todo modes are irreconcilable (Node reports a failing todo as
passing, Bun runs todo bodies only under --todo, Deno never runs them),
so the tag's semantics live in the registered body instead: a @todo
scenario runs as a plain test on every runtime, its declared failure is
printed and gates nothing — and the run that would first turn it green
goes red instead, naming the stale tag. Declared debt is visible while it
lasts and self-retiring when it's paid: the only exit is deleting the tag,
a one-line reviewed diff.
What still differs — the honest list, pinned by tests:
- 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.
And under vitest — via the adapter
The three runtimes above are the native path: scenarios register on the runtime's built-in runner. Vitest is the one external runner with a first-class adapter:
// test/features.test.ts
import { expect } from 'vitest';
import { runFeatures } from 'gherkin-node-test/vitest';
runFeatures('features', {
counter: (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) => expect(w.count).toBe(Number(n)));
},
});That is the whole integration: a plain spec file, collected by vitest like any
other. No plugin, no codegen, no .feature-to-.spec scaffolding. Everything
carries over — scoped registries, the binding ratchet and wip list, the
guard tests, the @only and duplicate-title rejections — because the adapter
is nothing but bindRunner(vitest.test): the same registration code pointed at
vitest's test() instead of the runtime's.
Why it exists: it removes the second parser. Under vitest-cucumber or cucumber-js, the executor parses your feature files with a different grammar than the linter that gates them, and any line the two parsers read differently is a place where the executed contract silently diverges from the linted one (see the two-parser rule). Under the adapter, the parse that lints the file is the parse that runs it.
Details worth knowing:
vitestis an optional peer dependency — only thegherkin-node-test/vitestentry imports it, so node/bun/Deno consumers never install it.- The one-
runFeatures-call-per-file rule is native-runner-only and does not apply under the adapter. It guards a Deno-specific load-throw swallow that vitest doesn't have (a collection-time throw is reported loudly), and vitest's watch mode re-executes a spec file in a worker whose module cache survives — the guard's bookkeeping would misread that re-run as a second call. - Unbound-step placeholders register as
test.todo; vitest reports them as todo and never runs their bodies, and the unbound-step ratchet still fails the suite through the guard test, exactly as on the native runtimes.@todoscenarios register as plain tests (the inversion carries the tag's semantics), so they behave under vitest exactly as everywhere else. bindRunner(testFn)is exported from the main entry too, for any other runner exposing the method-form shape:test(name, fn)plus.skip(name, fn)and.todo(name, fn). The shape is strict about todo taking a body — jest is not this shape (itstest.todorejects a second argument, and the throwing todo body can't be dropped to accommodate it: it's load-bearing underbun test --todo). The vitest entry is the only binding this package ships and CI-tests; anything else, you're holding the binding.
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 → inverted (runs everywhere, expected to fail, gates nothing — a passing @todo fails the run naming the stale tag); @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 is inverted
(xfail): the body runs on every runtime, an expected failure is printed and
gates nothing, and a @todo that passes fails the run naming the stale tag —
remove the tag and the scenario gates normally; @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.
Scenario Outline: one scenario, many values
When the same behavior should hold across a spread of inputs — the usual
suspects plus the extremes — write it once as a Scenario Outline and put the
values in the Examples table:
Scenario Outline: the counter reflects adding <amount>
Given a counter at 0
When I add <amount>
Then the counter is <amount>
Examples:
| amount |
| 5 |
| -3 |
| 0.5 |
| 9007199254740991 |Each row expands into its own independent test — fresh world, Background
re-run — named with the substituted title and a row suffix:
Counter :: the counter reflects adding -3 [2]. Placeholders substitute in
step text and step data tables; a <name> with no matching column is a
parse error, not a silent leak into the step text.
Table-cell substitution matters when the varying value has no natural home in the step sentence — it lives in a data-table cell instead, and the step text never mentions the placeholder at all:
Scenario Outline: rejects a malformed <field>
Given a submission
| field | value |
| email | [email protected] |
| <field> | <value> |
Then the submission is rejected
Examples:
| field | value |
| phone | nope |
| zip | -1 |One honest caveat: expansion is text substitution, so the step definitions
decide what actually binds. The quick-start steps match (\d+) — the -3 and
0.5 rows won't bind until you widen that to something like
(-?\d+(?:\.\d+)?). Forgetting isn't a quiet pass: the unbound-step guard
fails loudly and hands you the stub for the exact expanded text. And an
outline with a single Examples row gets a lint warning
— that's a plain Scenario with extra ceremony, and usually a missing case.
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 }));
});Typed worlds (TypeScript, opt-in)
The world defaults to Record<string, any> and every runtime guard works
exactly the same either way. TypeScript consumers can opt into a typed
world — StepRegistry<W> threads W to every step, and Definer<W> is
the seam for step modules handed to runFeatures:
import { runFeatures, type Definer } from 'gherkin-node-test';
type CounterWorld = { count?: number }; // the ACCRETED shape — the world is born {}
const counter: Definer<CounterWorld> = (reg) => {
reg.define(/^a counter at (\d+)$/, (w, n) => { w.count = Number(n); });
reg.define(/^the count is (\d+)$/, (w, n) => { assert.strictEqual(w.count, Number(n)); });
};
runFeatures('features', { counter }); // one world PER FEATURE — no type param hereStep args are typed string | DataTable — captures arrive as strings, so
the Number() coercion stays in view instead of silently concatenating.
Be honest about what this buys: it is enforced by your build, not by the
runner, and it proves a world key is spelled consistently — never that a
step has assigned it. Keep W's fields optional; a required field on a
world that is born {} is a type-level claim no Given has made true yet.
The run-time half — assigned is a fact no type can promise — has a recipe, not a guarantee: a throwing accessor, used wherever a Then reads what a Given should have set (from the gh#4 step-fidelity audit — @llowrey):
// Every unset-read becomes an immediate, explanatory failure — instead of
// an `undefined` coasting through a negative assertion, where it passes.
const required = <T,>(value: T | undefined, what: string): T => {
if (value === undefined) throw new Error(`world.${what} read before any step set it`);
return value;
};
reg.define(/^the count is (\d+)$/, (w, n) => {
assert.strictEqual(required(w.count, 'count'), Number(n));
});Why it exists at all: the practice of auditing agent-written step
code — the field report behind
this feature was a reviewer drowning in as MyWorld casts while checking
that the robot was actually testing things.
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 Feature: with no scenarios | a header plus narrative registers nothing and reads as a passing file — the same vacuous pass, one level up. (This is also the one shape a prose requirement file takes: someone wrote the spec down, and nothing checks any of it) |
| 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-scenarios | error | a Feature: header with no scenarios under it — the file enforces nothing and reads as passing. The same parser refusal as dialect (and so also always alone), under its own rule name: "add a scenario or delete the file" is a different remedy than "fix this line" |
| 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 |
| near-miss-keyword | warn | a silently dropped line that was almost certainly meant as syntax: a wrong-case step keyword inside a scenario or Background body (when I add 5, GIVEN a counter), or — anywhere — a construct header that isn't the one exact form the parser recognizes (scenario: b, Scenario : b, SCENARIO OUTLINE: b) |
| dropped-prose | warn | any other line inside a scenario, outline, or Background body that the parser dropped as narrative — and any non-tag, non-comment line above the Feature: header. The floor under near-miss-keyword: no dropped line goes unaccounted. Prose in a body reads like a requirement and enforces nothing; make it a step, or a # comment if it's commentary. The Feature narrative (below the header) stays exempt — prose is its job |
| duplicate-title | error | a Scenario or Scenario Outline title already used earlier in the file — compared pre-expansion, because two outlines sharing a title expand to byte-identical test names (the [n] suffix indexes rows within one outline, not across outlines) |
| unused-column | warn | an Examples column no <placeholder> in the outline's title, steps, or step tables ever references — a case someone wrote down that no assertion consumes. Reported at the header row's line |
| strict-tag | error (strict only) | a @skip or @only tag, in strict mode. Reviewed output carries no silent debt or focus: a committed skip hides a scenario from every run with no ledger entry, and focus is a per-run CLI flag. @todo is deliberately exempt — the stale-@todo inversion polices it at run time, so a committed @todo is honest, visible, self-retiring debt |
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.
Strict mode is one bit: lintFeature(text, filename, { strict: true })
promotes every warning to an error — same rule, same line, same message,
nothing removed or reworded — and adds the strict-only strict-tag rule. A
strict-clean file is clean in default mode by construction, and there is no
relaxed mode: strict promotes, nothing demotes. New rules enter the gate only
through the four admission tests of
docs/lint-admission.md, which also binds the Rust
sibling in lockstep — finding text is byte-identical across the two
implementations, held there by a differential parity corpus.
near-miss-keyword is the counterpart to the near-miss tag the parser already
rejects outright (@Skip, @Only). Keywords are exact; anything else on a
step line is narrative, and narrative is ignored without a finding — that is how
the As a… / I want… block is skipped, and it cannot change. The no-steps guard
and no-then between them catch a dropped step only when it was a scenario's
only step, or its only Then. A near miss in a scenario that still has a Given
and a Then is otherwise invisible. The step half of the rule is scoped to
scenario and Background bodies, because the Feature narrative is prose by
design — and a correctly cased step out there is already the dialect error
"step before any Scenario or Background".
The construct half is not scoped: a lowercase scenario: is worse than a
lowercase when, because the scenario never exists and its steps silently merge
into the previous scenario — unseeable by the no-steps guard and no-then
precisely because there is no scenario to inspect. The trailing colon makes a
construct near miss syntax-shaped wherever it appears, so wrong spacing is
flagged along with wrong case (Scenario : b, ScenarioOutline: b). Rule: is
exempt: the exact form is itself a dialect error in this subset, so a near
miss is not a rescue — and rule: never deploy on Friday is plausible prose.
The rule reads the narrative lines off the parse (parseFeature records what it
drops), so what the linter checks and what the parser drops cannot drift apart.
duplicate-title guards the library's own prescription. The @only
rejection tells you to focus one scenario by title pattern
(--test-name-pattern / -t / --filter) — and a duplicated title breaks
that silently: the pattern matches every copy, and failure reports cannot tell
them apart. So the runner refuses it the same way it refuses @only: a
registered failing test, additive — both copies still register and run,
nothing narrows. In practice a duplicate is a copy-paste-edit where the rename
was forgotten; check whether the body edit was forgotten too.
unused-column is the inverse of the unknown <placeholder> dialect error,
and deliberately softer: a leading label column (| case | … |) kept for the
human reader is legitimate style, so an unreferenced column warns rather than
errors. Repos where every column must be load-bearing escalate the warn in
their guard test.
Severity is descriptive, not policy. dialect is an error because the runner
would refuse the file, and duplicate-title because the runner refuses it
too; the remaining 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 → not a reason anymore: use
gherkin-node-test/vitest. Reach for vitest-cucumber instead if you want its generated-spec workflow (.feature→ scaffolded.spec.ts) rather than step registries. On Jest → jest-cucumber (jest'stest.todorejects a body, sobindRunnerdoes not fit it — see the adapter section). - 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, manifest }?) | high-level runner: discover every .feature, scoped registries, guard tests; wip takes basenames or { feature, scenarios }; manifest opts into the run manifest; 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 |
| lintStepDefinitionSource(text, filename?, config?) | step-source linter: the other side of the contract — unearned-absence (literal-needle negations, every assertion dialect) and rest-signature sightings, warn-class, sanctioned by a statement-attached // step-lint: allow <rule> -- <reason> marker whose staleness is itself sighted; config.rules adds { pattern, reason } pairs (all sharing the rule name custom — one allow custom marker sanctions every config rule on that statement). Scan everything your steps import — the field incident behind this rule came from files outside the lint's roots. |
| StepRegistry | .define(pattern, fn) / .find(text); optionally generic — StepRegistry<W> types the world |
| Definer<W>, Registry<W>, StepFn<W> (types) | the typed-world seams; also re-exported by the vitest entry |
| executeSteps(steps, registry, world?) | run a flat step list against a shared world (installs world.defer); every invocation passes the args-consumption check — produced vs declared, defaults refused, rest exempt |
| runFeature(parsed, registry) | register one runner test per scenario (@skip/@todo mapped; @only, duplicate titles, and unconsumed definitions → failing test; unbound → TODO) — the registry is one feature's, so a registry shared across files answers for full consumption in each |
| runFeatureFile(file, registry) | read + parse + run a single .feature file |
| bindRunner(testFn) | rebind runFeature/runFeatureFile/runFeatures to a method-form test function (.skip/.todo) — how gherkin-node-test/vitest is built |
| 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
