@epure/vitest

Reference·v1.x·TypeScript & ReScript signatures

API Reference

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.

epureVitest(options?)

GherkinSince 0.1

The Vite plugin — compiles feature files and Gherkin-in-Markdown into Vitest suites.

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

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

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

Given(pattern, build, ...params)

GherkinSince 0.1

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

function Given(pattern: string, build: (steps: Context, ...params: Param[]) => void | Promise<void>): void
let given: (string, (given, 'a) => 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, 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: 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
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);
  });
});
open EpureVitest

given("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)
  })
})

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 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 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        |
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));
  });
});
open EpureVitest

given("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))
  })
})

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

Contract
Then the decks are
  | spanish |
  | physics |
Then("the decks are", (table) => {
  expect(store.deckNames).toEqual(toStrings(table));
});
step("the decks are", table => {
  expect(store.deckNames).toEqual(toStrings(table))
})

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, thresholds — and the step wants them ready for arithmetic. Siblings: toStrings, toRecords. One column, no header row — each cell one number:

Contract
Then the review intervals are
  | 2 |
  | 4 |
  | 8 |
Then("the review intervals are", (table) => {
  expect(scheduler.intervals).toEqual(toNumbers(table));
});
step("the review intervals are", table => {
  expect(scheduler.intervals).toEqual(toNumbers(table))
})

stepsResolver(path)

GherkinSince 0.6

The default mapping from a feature 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 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.

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 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.
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 functions the Given builder destructures to register When, Then, and friends.

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

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 — 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.

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

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 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(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, 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
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")