The public surface of @epure/vitest: the plugin, the step definitions, 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 a Vite plugin. Registered in the Vitest config, it intercepts every file matching gherkinExtensions (and gherkin code fences in files matching markdownExtensions), parses it with the official Cucumber parser, finds the matching steps file through stepsResolver, and hands Vitest an ordinary suite. There is no separate runner and no generated code on disk: the feature file is the test file, listed in test.include like any other.
Scenarios run concurrently by default — each Given builds its own context, so there is no shared world to serialize on. Set concurrent: false if a suite genuinely needs order. See 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, receiving the step-binding context first, then the captured parameters, then the Vitest TestContext last. Everything the scenario needs is created inside the builder, and every When/Then the builder registers closes over it — no world object, no shared state between scenarios. See Step for the context's shape, and guide chapter Steps close over the world.
(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 captures at most one parameter — capture further values in a step or pass a table.
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",({ When, Then }, name:string)=>{const calculator =makeCalculator(name);When("I add {number} and {number}", calculator.add);Then("the result is {number}",(n:number)=>{expect(calculator.result).toBe(n);});});
openEpureVitestgiven("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 assertions want when comparing against a list of domain objects. Values stay strings; parse them where the domain requires numbers or booleans. See also toStrings and toNumbers, and 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 one.
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",({ When, Then }, data)=>{const table =makeTable(toRecords(data));When("I sort by {string}", table.sort);Then("the table is",(expected)=>{expect(table.list).toEqual(toRecords(expected));});});
openEpureVitestgiven("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, with no header row semantics. 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, thresholds — and the step wants them ready for arithmetic. Siblings: toStrings, toRecords. One column, no header row — each cell one number:
The default mapping from a feature 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 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 let the default error surface. The resolver is exported so 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 guide chapter Contracts in the prose.
rescriptExtensions (default [".res"]) — ReScript sources translated with source maps.
stepsResolver (default stepsResolver) — how a feature 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 functions the Given builder destructures to register When, Then, and friends.
typeStep=(pattern:string, op: Operation)=>void
typegiven={step:'a.(string,'a)=> unit}
The first argument of a Given builder is a Context: a record whose every field is a Step. In TypeScript the record is a proxy, so any name you destructure becomes a binder — When, Then, And, But, or Quand and Alors for a French contract. The names carry no semantics; matching is by pattern, so choose the words that read best next to the feature file. In ReScript the record has the single field step, used for every keyword.
Step patterns use the same Cucumber expressions and (s) shorthand as Given: the queue has {number} card(s) binds sentences 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 — one binding serves whichever keyword introduces the sentence.
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}",({ When, Then, But }, name:string)=>{const deck =makeDeck(name);When("I review {string}", deck.review);Then("the queue has {number} cards",(n:number)=>{expect(deck.queue.length).toBe(n);});But("nothing is due tomorrow",()=>{expect(deck.dueTomorrow).toEqual([]);});});
given("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([])})})
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 — same behavior, same failure messages, same snapshot machinery — with a typed surface: the matcher must receive the same type as the asserted value. The available matchers are listed under assertions; modifiers not, resolves and rejects chain as in Vitest.
resolves and rejects return promise-based assertions. Await them — ReScript's type system makes an unawaited one a compile error when anything follows it, which is a small gift: the dangling-assertion bug class does not compile. See guide chapter Vitest in ReScript.
The statics of Vitest's expect — soft, poll, asymmetric matchers, extension points.
expect.soft(...)/ expect.poll(...)/ expect.closeTo(...) — statics on expect itself
let expected: expected
In JavaScript, expect is both a function and a bag of statics. ReScript types cannot express that duality, so the statics live under a second binding to the same object: expected. It carries soft (record the failure, 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, stringMatching. 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 bind Vitest's identical pair; pick the one that reads better in the sentence. The body may be synchronous or async — source maps carry through the ReScript compiler, so a failure points at the .res line, not the compiled .mjs. bench is bound alongside 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, beforeEach/afterEach bracket every test. onTestFinished registers per-test cleanup from inside the test itself — the better home for teardown that belongs to one test's setup rather than the whole suite.
Contracts rarely need any of these: a Given builder runs per scenario and is the setup, with cleanup in closure reach. Reach for 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.