@epure/vitest

Reference·v1.x·TypeScript & ReScript signatures

API Reference

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.

epureVitest(options)

GherkinSince 0.1

Compile Gherkin, Markdown, YAML, and ReScript files for Vitest.

function epureVitest(options?: EpureVitestOptions): Plugin
// 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.

Example
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { epureVitest } from "@epure/vitest";

export default defineConfig({
  plugins: [epureVitest()],
  test: {
    include: ["**/*.feature", "**/*.test.yaml", "**/*.spec.ts", "**/*.mdx"],
  },
});

Given(pattern, build)

GherkinSince 0.1

Bind a Given pattern to a builder that creates the scenario's context and its steps.

function Given(pattern: string, build: (handle: Handle, ...params: Param[]) => void | Promise<void>): void
let given: (string, given => unit) => unit

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: Calculator

  Background:
    Given I have a "basic" calculator

  Scenario: Add two numbers
    When I add 1 and 2
    Then the result is 3

  Scenario: Order does not matter
    When I add 2 and 1
    Then the result is 3

toRecords(table)

GherkinSince 0.6

Convert a Gherkin data table into records keyed by its header row.

function toRecords(table: string[][]): Record<string, string>[]
let toRecords: array<array<string>> => array<'a>

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 table

  Scenario: Sorting by name
    Given I have a table
      | name  | interval |
      | perro | 4        |
      | gato  | 2        |
    When I sort by "name"
    Then the table is
      | name  | interval |
      | gato  | 2        |
      | perro | 4        |

toStrings(table)

GherkinSince 0.6

Flatten a one-column Gherkin table into a list of strings.

function toStrings(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:

Contract
Then the decks are
  | spanish |
  | physics |

toNumbers(table)

GherkinSince 0.6

Flatten a one-column Gherkin table into a list of numbers.

function toNumbers(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:

Contract
Then the review intervals are
  | 2 |
  | 4 |
  | 8 |

stepsResolver(path)

GherkinSince 0.6

The default mapping from a contract file to its steps file.

function stepsResolver(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.

Example
import { defineConfig } from "vitest/config";
import { stepsResolver, epureVitest } from "@epure/vitest";

export default defineConfig({
  plugins: [
    epureVitest({
      stepsResolver: (path) =>
        stepsResolver(path) ?? path.replace(/\.feature$/, ".bindings.ts"),
    }),
  ],
});

EpureVitestOptions

GherkinSince 0.1

Configuration accepted by the epureVitest plugin.

type EpureVitestOptions = { debug?; concurrent?; markdownExtensions?; gherkinExtensions?; rescriptExtensions?; stepsResolver?; resCompiledResolver? }
// 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.
Example
import { defineConfig } from "vitest/config";
import { epureVitest } from "@epure/vitest";

export default defineConfig({
  plugins: [epureVitest({ markdownExtensions: [".mdx"], concurrent: true })],
  test: { include: ["**/*.feature", "**/*.mdx"] },
});

resCompiledResolver(path)

GherkinSince 0.4

Resolve a ReScript source file to its compiled JavaScript module.

function resCompiledResolver(path: string): string | null
// used by the TypeScript plugin configuration

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.

Example
import {
  epureVitest,
  resCompiledResolver,
} from "@epure/vitest";

epureVitest({
  resCompiledResolver(path) {
    return resCompiledResolver(path);
  },
});

Step

GherkinSince 0.1

A step binder — the handle's one word for registering every scenario operation.

type Step = (pattern: string, op: Operation) => void
type given = {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 repetition

  Scenario: Passed cards leave the queue
    Given a deck named "spanish"
    When I review "gato"
    And I review "perro"
    Then the queue has 0 cards
    But nothing is due tomorrow

Given(name, handle)

YAMLSince 1.2

Bind a YAML given name to a handler receiving the handle and the example data.

function Given(name: string, build: (handle: Handle, data: Record<string, unknown>) => void | Promise<void>): void
// YAML fixture steps are a TypeScript API

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: 1
    right: 2
    result: 3
  - scenario: adds negative numbers
    left: -4
    right: 2
    result: -2

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.

expect(actual)

VitestSince 0.4

Vitest's expect, typed for ReScript — the assertion entry point.

function expect<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.

Example
import { expect } from "vitest";

expect(calculator.result).toBe(0.5);
await expect(fetchDeck("spanish")).resolves.toHaveLength(3);
open EpureVitest

expect(calculator.result).toBe(0.5)
await expect(fetchDeck("spanish")).resolves.toHaveLength(3)

expected

VitestSince 0.6

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 inside expect 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.

Example
expect({ title: "0.1 + 0.2", sum: 0.1 + 0.20002 }).toEqual({
  title: expect.any(String),
  sum: expect.closeTo(0.3, 2),
});
expect({title: "0.1 + 0.2", sum: 0.1 +. 0.20002}).toEqual({
  title: expected.any(OfType.string),
  sum: expected.closeTo(0.3, ~precision=2),
})

describe(name, fn)

VitestSince 0.4

Group tests under a name — with concurrent, skip, only and todo variants.

function describe(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", () => { /* … */ });
});
open EpureVitest

describe("Scheduler", () => {
  it("reschedules a passed card", () => { /* … */ })
})

test(name, fn)

VitestSince 0.4

Declare one test — test and it are the same binding, sync or async.

function test(name: string, fn: () => void | Promise<void>): void
let test: (string, 'a) => unit

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(await parse("-15")).toBe(-15);
});
open EpureVitest

it("parses negative numbers", async () => {
  expect(await parse("-15")).toBe(-15.0)
})

beforeEach(fn)

VitestSince 0.6

Lifecycle hooks — beforeAll, beforeEach, afterEach, afterAll, onTestFinished.

function beforeEach(fn: () => void | Promise<void>): void
let beforeEach: (string, 'a) => unit

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
open EpureVitest

describe("Store", () => {
  beforeEach("open db", () => Db.openInMemory())
  afterEach("close db", () => Db.close())

  it("persists a card", () => { /* … */ })
})

assertions

VitestSince 0.6

The typed matcher surface returned by expect.

type Assertion<T> // vitest's own — @epure/vitest adds nothing on the TS side
type rec 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.

Example
expect([1, 2, 3]).toContain(2)
expect("hello world").toMatch(/world/)
expect(5.0).toBeGreaterThanOrEqual(5.0)

let boom = () => throw(Failure("fail"))
expect(boom).toThrowError(~message="fail")