The public surface of @epure/vitest: Gherkin contracts, structured YAML fixtures, and the ReScript bindings for Vitest. Each entry gives the signature, behavior, and one minimal example.
// configured in vitest.config.ts — the plugin side stays in TypeScript
epureVitest returns one Vite plugin for every supported contract format. It
compiles .yaml fixtures, files matching
gherkinExtensions, gherkin code fences in files
matching markdownExtensions, and configured ReScript files. Vitest's
test.include controls which files are tests. There is no separate runner and
no generated code on disk.
Scenarios run concurrently by default — each Given builds its own context, so there is no shared world that requires serialized access. Set concurrent: false if a suite genuinely requires sequential execution. See the guide chapter Wired into Vitest.
The former vitestBdd export remains as a deprecated alias and warns once per
process when called.
Given is the single entry point of a steps file. The pattern uses Cucumber expressions — {string} and {number} capture parameters — and the builder runs once per scenario. It receives the handle first — step registers a scenario operation, test is the running Vitest test — followed by the captured parameters. Everything the scenario needs is created inside the builder, and every operation registered through step closes over it — no world object and no shared state between scenarios. The feature file keeps its own words — When, Then, Alors — while the code has one: step. See Step for the handle's shape and the guide chapter Steps close over the world.
YAML fixtures use the same registration. For YAML, the handler receives the
handle first and the scenario's structured data in the parameter position;
see Given for YAML.
(s) at the end of a word expands one pattern into its singular and plural forms: I count {number} time(s) binds both I count 1 time and I count 2 times. It is matching shorthand, not agreement validation — either form accepts any captured number. Keep (s) in the steps file; the feature file remains ordinary prose.
The builder may be async: the runner awaits it before executing steps. In ReScript, given binds a pattern with no captures; given1 and given2 bind one and two.
A Background: section states the shared situation once: its steps are prepended to every scenario's steps, and it must start with a Given — that is the step that opens the context everything else closes over.
Contract
Feature: CalculatorBackground:GivenI have a "basic" calculator
Scenario: Add two numbersWhenI add 1 and 2
Then the result is 3
Scenario: Order does not matterWhenI add 2 and 1
Then the result is 3
import{ expect }from"vitest";import{ Given }from"@epure/vitest";import{ makeCalculator }from"../feature/calculator";Given("I have a {string} calculator",({ step }, name:string)=>{const calculator =makeCalculator(name);step("I add {number} and {number}", calculator.add);step("the result is {number}",(n:number)=>{expect(calculator.result).toBe(n);});});
openEpureVitestgiven1("I have a {string} calculator",({step}, name:string)=>{let calculator =Calculator.make(name)step("I add {number} and {number}", calculator.add)step("the result is {number}",(n:float)=>{expect(calculator.result).toBe(n)})})
A data table reaches a step as raw rows of strings. toRecords reads the first row as field names and returns one record per remaining row — the shape that assertions expect when comparing against a list of domain objects. Values remain strings; parse them wherever the domain requires numbers or booleans. See also toStrings, toNumbers, and the guide chapter Tables and other tongues.
In the feature file, the table sits under its step, header row first, and arrives as that step's last parameter — here both Given and Then receive the table as their last parameter.
Contract
Feature: The card tableScenario: Sorting by nameGivenI have a table| name | interval || perro | 4 || gato | 2 |WhenI sort by "name"Then the table is| name | interval || gato | 2 || perro | 4 |
import{ Given, toRecords }from"@epure/vitest";Given("I have a table",({ step }, data)=>{const table =makeTable(toRecords(data));step("I sort by {string}", table.sort);step("the table is",(expected)=>{expect(table.list).toEqual(toRecords(expected));});});
openEpureVitestgiven1("I have a table",({step}, data)=>{let table =Table.make(toRecords(data))step("I sort by {string}", table.sort)step("the table is", expected =>{expect(table.list).toEqual(toRecords(expected))})})
Flatten a one-column Gherkin table into a list of strings.
functiontoStrings(table:string[][]):string[]
let toStrings: array<array<string>>=> array<string>
toStrings takes the first column of a data table and returns it as a plain list — for tables that are really just an enumeration, without treating the first row as a header. Its numeric sibling is toNumbers; for header-driven tables, use toRecords. One column, no header row — the whole table is the list:
Flatten a one-column Gherkin table into a list of numbers.
functiontoNumbers(table:string[][]):number[]
let toNumbers: array<array<string>>=> array<float>
toNumbers takes the first column of a data table and parses each cell as a number. Use it when a scenario enumerates values — intervals, amounts, or thresholds — and the step needs them ready for arithmetic. Its sibling helpers are toStrings and toRecords. One column, no header row — each cell represents one number:
The default mapping from a contract file to its steps file.
functionstepsResolver(path:string):string|null
// resolution happens in the plugin — override it from vitest.config.ts
Given test/cards.feature, the default resolver tries test/cards.feature.ts,
then test/cards.steps.ts, then test/cardsSteps.ts — each across the
extensions .ts, .tsx, .js, .jsx, .mjs, .cjs, .res.mjs,
.res.jsx, .res.tsx — and finally a shared steps.* in the same directory.
The same convention applies to every format: calculator.test.yaml starts
with calculator.test.yaml.ts, then tries calculator.test.steps.ts. The
Steps suffix exists for ReScript, whose module names forbid dots:
test/Cards.feature finds test/CardsSteps.res.
Pass your own function as the stepsResolver option of epureVitest to change the convention; return null to allow the default error to surface. The resolver is exported so that a custom one can fall back on it.
// plugin options are written in vitest.config.ts (TypeScript)
All fields are optional; the defaults are the convention this documentation assumes.
The former VitestBddOptions name remains as a deprecated TypeScript alias.
concurrent (default true) — run scenarios concurrently. Safe because each scenario builds its own context; disable only for suites that share external state.
gherkinExtensions (default [".feature"]) — files parsed as pure Gherkin.
markdownExtensions (default [".md", ".mdx", ".markdown"]) — files scanned for gherkin code fences; see the guide chapter Contracts in prose.
.yaml files are always compiled as structured fixtures; use Vitest's
test.include to select them.
rescriptExtensions (default [".res"]) — ReScript sources compiled with source maps.
stepsResolver (default stepsResolver) — how a
feature, Markdown, or YAML file finds its steps.
resCompiledResolver — how a ReScript source finds its compiled JavaScript module.
debug (default false) — log the generated suite during compilation.
The default resolver checks the compiled forms emitted beside a .res source.
Pass a custom resCompiledResolver to epureVitest when
the ReScript output uses another location or suffix.
A step binder — the handle's one word for registering every scenario operation.
typeStep=(pattern:string, op: Operation)=>void
typegiven={step:'a.(string,'a)=> unit, test: testContext}
The first argument of a Given builder is a Handle with two fields: step, a Step, and test, the running Vitest TestContext. The keywords carry no semantics — matching is by pattern — so the feature file keeps its own words, When, Then, And, But, Quand, Alors, while the code has one: step. One binding serves whichever keyword introduces the sentence. test is for per-scenario teardown: test.onTestFinished runs when the scenario's test ends. The shape is the same in TypeScript and ReScript.
Step patterns use the same Cucumber expressions and (s) shorthand as Given: the queue has {number} card(s) matches steps ending in either card or cards. The feature file still says the concrete sentence; (s) belongs only to the binding pattern.
The operation receives the step's captured parameters — {string} as string, {number} as number, a trailing data table as string[][] — and may be async; the runner awaits it. Binding a pattern twice replaces the first operation. A step present in the feature file but never bound fails the scenario with Step "…" not found. In the feature file, And and But continue the previous kind of step and match by pattern like any other.
Contract
Feature: Spaced repetitionScenario: Passed cards leave the queueGivena deck named "spanish"WhenI review "gato"AndI review "perro"Then the queue has 0 cards
But nothing is due tomorrow
Given("a deck named {string}",({ step }, name:string)=>{const deck =makeDeck(name);step("I review {string}", deck.review);step("the queue has {number} cards",(n:number)=>{expect(deck.queue.length).toBe(n);});step("nothing is due tomorrow",()=>{expect(deck.dueTomorrow).toEqual([]);});});
given1("a deck named {string}",({step}, name:string)=>{let deck =Deck.make(name)step("I review {string}", deck.review)step("the queue has {number} cards",(n:float)=>{expect(deck.queue->Array.length->Int.toFloat).toBe(n)})step("nothing is due tomorrow",()=>{expect(deck.dueTomorrow).toEqual([])})})
Import Given from @epure/vitest in the fixture's steps module, just as for
a feature file. Its name matches the scenario's given, or
background.given when the scenario does not provide one. Its handler receives
the handle first, then every field except scenario and given. The
handle's step can register operations, though YAML fixtures do not execute
them yet; its test is the running Vitest test.
Handlers may be asynchronous. Each key can be registered once per test process.
Contract
feature: YAML calculator
background:given: a calculator
examples:-scenario: adds two numbers
given: a calculator
left:1right:2result:3-scenario: adds negative numbers
left:-4right:2result:-2
// calculator.test.yaml.tsimport{ Given }from"@epure/vitest";import{ expect }from"vitest";Given("a calculator",({ test },{ left, right, result })=>{expect(Number(left)+Number(right)).toBe(result);expect(test.task.name).toBeTypeOf("string");});
This is the same Given registration used by Gherkin. Its
arguments occupy the same positions: the handle first, contract data where a
feature places its captures.
Vitest's expect, typed for ReScript — the assertion entry point.
functionexpect<T>(actual:T): Assertion<T>
let expect:'a => assertions<'a>
expect binds Vitest's own function — with the same behavior, failure messages, and snapshot machinery — to a typed surface: the matcher must receive the same type as the asserted value. The available matchers are listed under assertions; the not, resolves, and rejects modifiers chain as they do in Vitest.
resolves and rejects return promise-based assertions. Await them — ReScript's type system makes an unawaited assertion a compile error when anything follows it, eliminating this class of dangling-assertion bug. See the guide chapter Vitest in ReScript.
The static properties of Vitest's expect — soft, poll, asymmetric matchers, and extension points.
expect.soft(...)/ expect.poll(...)/ expect.closeTo(...) — static properties on expect itself
let expected: expected
In JavaScript, expect is both a function and an object with static properties. ReScript types cannot express that duality, so the static properties live under a second binding to the same object: expected. It provides soft (record the failure and keep running), poll (retry an assertion until it passes or times out), assertions/hasAssertions (count guards for async tests), unreachable, and the asymmetric matchers used insideexpect comparisons: closeTo, anything, any, arrayContaining, objectContaining, stringContaining, and stringMatching. The extension points extend, addSnapshotSerializer, and addEqualityTesters are bound as well.
expected.any takes a constructor; the OfType module provides string, number, boolean and array for the common cases.
Group tests under a name — with concurrent, skip, only and todo variants.
functiondescribe(name:string,fn:()=>void):void
let describe:(string, unit => unit)=> unit
The standard Vitest grouping, bound for ReScript. concurrent binds describe.concurrent for suites whose tests may interleave. The mode variants live in nested modules so call sites read like the JavaScript they compile to: Skip.describe parks a suite without deleting it, Only.describe narrows a run while debugging, Todo.describe records a name with no body yet. The same three modules wrap test, it and bench.
Feature files never call describe — the plugin generates the suite from the Gherkin structure. This binding is for the unit tests you write alongside contracts: the pure business floor, tested function by function. See guide chapter Vitest in ReScript.
Example
import{ describe, it }from"vitest";describe("Scheduler",()=>{it("reschedules a passed card",()=>{/* … */});});
openEpureVitestdescribe("Scheduler",()=>{it("reschedules a passed card",()=>{/* … */})})
test and it are bindings for Vitest's identical functions; pick the one that reads better in the sentence. The body may be synchronous or async — source maps survive the ReScript compiler, so a failure points to the .res line, not the compiled .mjs. bench is bound alongside them for benchmarks, and Skip, Only, and Todo (see describe) provide the mode variants.
Example
import{ expect, it }from"vitest";it("parses negative numbers",async()=>{expect(awaitparse("-15")).toBe(-15);});
The four suite hooks are bound as in Vitest: beforeAll/afterAll bracket a suite, while beforeEach/afterEach bracket every test. onTestFinished registers per-test cleanup from inside the test itself — a better place for teardown that belongs to one test's setup rather than the whole suite.
Contracts rarely need any of these: a Given builder runs once per scenario and is the setup, with cleanup available through its closure. Use hooks in unit suites that manage an external resource.
Example
openEpureVitestdescribe("Store",()=>{beforeEach("open db",()=>Db.openInMemory())afterEach("close db",()=>Db.close())it("persists a card",()=>{/* … */})})
typeAssertion<T>// vitest's own — @epure/vitest adds nothing on the TS side
typerec assertions<'a>={not: assertions<'a>, toBe:'a => unit, toEqual:'a => unit, resolves: passertions<'b>,...}
The record behind expect: equality (toBe, toEqual, toStrictEqual, toMatchObject), truthiness (toBeTruthy, toBeNull, toBeDefined, …), numeric comparisons (toBeCloseTo, toBeGreaterThan, …), strings (toMatch), collections (toContain, toContainEqual, toHaveLength, toHaveProperty, toBeOneOf), functions (toThrow, toThrowError), predicates (toSatisfy), and the snapshot family (toMatchSnapshot, toMatchInlineSnapshot, toMatchFileSnapshot, and the throwing variants).
Matchers are typed against the asserted value: expect(5.0) offers float comparisons, expect([1, 2]) offers array matchers. The not modifier returns the same record negated. resolves and rejects switch to passertions, the promise-returning twin of this record — every matcher there must be awaited.