@epure/vitest

Guide·@epure/vitest·Guided tour

The Guide

A reading path through @epure/vitest, in order: why a contract must run, the shape of a feature file, steps as closures, the wiring into Vitest, tables and other languages, contracts inside prose, and writing Vitest in ReScript. Read it top to bottom once. When you need to look something up later, use the API reference instead — it is flat, complete, and made for that.

01

An opinion until it runs

This chapter is for the person deciding whether @epure/vitest belongs in their stack. There is no code in it.

A specification nobody executes is an opinion. It starts true, drifts quietly, and is discovered to be fiction at the worst possible moment — usually by the person who signed it. Every team owns a document like this: the requirements page, the acceptance checklist, the PDF with the rules in it. The code moved; the document did not; nobody can say when they parted.

@epure/vitest exists to remove the gap by removing the second artifact. A .feature file — plain Given/When/Then that the people who own the rules can read — is handed to Vitest as a test suite, directly. Not generated into one, not mirrored by one: the file business reads is the file the runner executes. When reality drifts from the contract, the build fails and the failure names the clause.

Two things, deliberately small

The library does exactly two jobs.

It runs Gherkin under Vitest, with steps as closures. A Vite plugin parses feature files (and Gherkin blocks inside Markdown) and compiles them, in memory, into ordinary Vitest suites — watch mode, concurrency, and failure reporting included. The steps that give each sentence meaning are plain functions: a Given builds the scenario's world, and every When and Then closes over it. There is no World object, no step registry shared across files, no regular-expression soup. If you have written a function that returns functions, you have written @epure/vitest steps.

It ports Vitest to ReScript. Complete typed bindings — expect, the full matcher surface, suites, hooks, modes — so a ReScript codebase writes its unit tests and its step definitions in its own language, with source maps pointing failures at the .res line.

The landing page borrows a word for the first job: compas — the divider, the tool that steps a drawing off against the work. Each test run is that gesture: the contract measured against the build, clause by clause.

The proof sheet of épure

In the épure method, feature files are written before the code, by a domain expert and a method expert together, and signed by the people who own the need. From that moment the contract governs the whole structure: the AI implements against it, the senior reviews against it, and @epure/vitest is what makes the governing literal — every scenario runs green or the window does not close. tilia is the foundation sheet, @tilia/query the data sheet; this is the proof sheet, and this guide is the last panel of the drawing.

How this guide works

Readers of the tilia and @tilia/query guides know Alice and her spaced-repetition flashcards. Her app has grown up: a language school uses it now, and the scheduling rules — when a failed card returns, how intervals stretch — are no longer hers to decide.

Story

Nadia teaches Spanish and owns the review rules. Last month Alice shipped a change that rescheduled failed cards to the next day. Nadia had signed off — on a PDF. The PDF still says same day.

Each chapter is one part of the tool: the shape of a contract, steps as closures, the wiring into Vitest, tables and languages, contracts inside prose, and the ReScript port. If you decide for your team, this chapter and the last may be all you need. The chapters between are for whoever writes the contracts — and whoever, human or machine, is held to them.

02

The shape of a contract

A contract is a text file with the .feature extension, and its grammar is Gherkin — small enough to learn in the time it takes to read this chapter. Alice and Nadia write the first one together, Nadia dictating, Alice typing:

Contract
Feature: Review scheduling

  Scenario: A passed card waits longer each time
    Given a card "gato" with interval 2 days
    When I review "gato" and pass
    Then "gato" is scheduled 4 days out

  Scenario: A failed card returns the same day
    Given a card "gato" with interval 8 days
    When I review "gato" and fail
    Then "gato" is scheduled 0 days out
    And the interval of "gato" is 1 day

That second scenario is the one the shipped bug violated — now it is two lines of plain language with a pass/fail verdict attached.

The grammar, entirely

A Feature: names the sheet. Each Scenario: is one test: it begins with Given (the situation), acts with When, and asserts with Then. And and But continue whichever kind of step came before — they are readability, not semantics. Values in quotes and bare numbers are parameters; the step definitions capture them. A Background: section holds steps shared by every scenario in the file, and data tables carry structured values — both wait until chapter five.

That is the whole grammar. The point of the smallness is who it admits: Nadia reads this file with no training, catches the wrong rule before implementation, and what she approves is — verbatim — what runs.

Story

Nadia reads the draft and stops at a line Alice thought was obvious: "No — failing a mature card resets the interval to one day, not zero. Zero means they see it twice in one session." One word changes in the contract. No code exists yet to fix.

Where contracts live

A feature file sits next to the code it governs, and its steps file sits next to it, found by naming convention: scheduling.feature looks for scheduling.feature.ts, then scheduling.steps.ts, then schedulingSteps.ts — the last form exists because ReScript module names cannot contain dots, so Scheduling.feature pairs with SchedulingSteps.res. A directory-wide steps.ts catches whatever remains, and the convention itself is replaceable.

In an épure project the features gather in the domain's test directory — src/domain/test/*.feature — one file per behavior, named after the need it answers. The contract is versioned with the code, reviewed in the same pull request, and signed in the tool where signatures mean something: the repository.

Pro tip

Write the feature file as if the code did not exist — name what the business observes, never how the code achieves it. "The card is scheduled 4 days out", not "the scheduler returns 4". A contract that names internals has already started to drift.

The sentences are signed. Making them executable is the job of the steps file — and that is where the design of @epure/vitest earns its keep.

Reference: steps-resolver

03

Steps close over the world

Classic BDD runners share one design: steps are registered globally, matched by regular expression, and communicate through a mutable "World" object passed to every function. It works, and it scales badly in a familiar way — any step can touch any state, so tracing what a scenario depends on means reading every step it might match.

@epure/vitest replaces the World with the oldest tool in the language: a closure.

Given builds, the rest remembers

Each scenario's Given runs a builder. The builder creates whatever the scenario needs — a domain object, a fake clock, an in-memory store — and registers the scenario's remaining steps as functions that close over it:

Example
// scheduling.feature.ts
import { expect } from "vitest";
import { Given } from "@epure/vitest";
import { makeScheduler } from "../feature/scheduler";

Given("a card {string} with interval {number} day(s)", ({ When, Then, And }, name: string, days: number) => {
  const scheduler = makeScheduler();
  const card = scheduler.add(name, { interval: days });

  When("I review {string} and pass", (n: string) => scheduler.review(n, "pass"));
  When("I review {string} and fail", (n: string) => scheduler.review(n, "fail"));

  Then("{string} is scheduled {number} day(s) out", (n: string, d: number) => {
    expect(scheduler.dueIn(n)).toBe(d);
  });
  And("the interval of {string} is {number} day(s)", (n: string, d: number) => {
    expect(scheduler.get(n).interval).toBe(d);
  });
});
// SchedulingSteps.res
open EpureVitest

given("a card {string} with interval {number} day(s)", ({step}, name: string) => {
  let scheduler = Scheduler.make()
  let _card = scheduler.add(name, ~interval=2.0)

  step("I review {string} and pass", n => scheduler.review(n, Pass))
  step("I review {string} and fail", n => scheduler.review(n, Fail))

  step("{string} is scheduled {number} day(s) out", (n, d: float) => {
    expect(scheduler.dueIn(n)).toBe(d)
  })
  step("the interval of {string} is {number} day(s)", (n, d: float) => {
    expect(scheduler.get(n).interval).toBe(d)
  })
})

Nadia keeps days and day in the contract; Alice writes day(s) only in the binding patterns. The shorthand registers both spellings, so one definition follows the contract's singular and plural sentences without leaking implementation notation into the prose.

scheduler is not global, not injected, not looked up: it is a local variable, and the steps see it because functions remember where they were born. Every dependency of the scenario is visible in one screenful, and "find usages" on makeScheduler answers the question a World never could.

What falls out of the closure

Concurrency for free. Each scenario calls the builder again and gets a fresh world; nothing is shared, so Vitest runs scenarios concurrently by default. Nobody serializes on a global.

Names are yours. The context the builder destructures is a proxy: When, Then, And, But — or Quand and Alors — any name becomes a step binder. Matching is by pattern text, so the binder names exist only to read well next to the feature file. ReScript keeps a single step field for all of them.

Async is ordinary. Builders and steps may be async; the runner awaits each in turn. The last builder parameter is Vitest's TestContext for the rare step that wants onTestFailed or a custom abort.

Reuse is function composition. A step set shared by several contracts — form assertions, say — is a function taking a binder and a subject. No registry, no inheritance: call it from any builder whose subject fits.

Pro tip

In ReScript, given captures at most one {string} or {number} parameter — a type-system limit. Need more? Put the extra values in a step or a table. In practice a Given with three parameters is usually a scenario trying to hide its setup.

Story

Alice deletes the fix she had started writing. The failed-card scenario runs red — expected 1, received 0 — pointing at the exact clause Nadia corrected. Now the fix has a definition of done that Nadia wrote.

The steps exist. What remains is to make Vitest treat a .feature file as one of its own.

Reference: given, step-type

04

Wired into Vitest

@epure/vitest is not a test framework. Vitest is the framework; @epure/vitest is a Vite plugin that teaches it to read Gherkin. The whole installation is one import and one line in test.include:

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"],
  },
});

When Vitest loads a .feature file, the plugin parses it with the official Cucumber parser, resolves the steps file by convention, and emits a suite in memory — nothing is generated on disk, nothing to regenerate or gitignore. The feature file appears in the test tree under its own name, each scenario a test, each step a line in the report.

Everything Vitest gives, contracts get

Because the contract is a first-class suite, every Vitest capability applies with no ceremony. Watch mode: save the feature file or the steps file and the affected scenarios re-run — Alice keeps the contract open in one split and the scheduler in the other, and the feedback loop is the same hot loop she has for unit tests. Filtering: vitest scheduling runs one contract. Reporting: a failed step names the feature file, the scenario, and the sentence, then shows the assertion diff.

Scenarios run concurrently by default. The closure design is what makes this safe — each scenario builds a private world, so there is nothing to race on. If a contract drives something genuinely shared (one database, one port), set concurrent: false in the options rather than hoping.

Pro tip

Feature files and unit specs coexist in one config and one run. The contracts pin behavior at the domain boundary; the .spec.ts files pin the functions beneath it. One vitest run in CI reports both — the window's "every scenario green" and the engineer's regression net are the same command.

Editors

Gherkin is plain text, so tooling is optional — but the Cucumber autocomplete extension for VS Code adds completion from your own step patterns, and the Vitest extension shows scenarios in the test explorer with run buttons, like any other test. The README lists the settings; the one that matters is pointing cucumberautocomplete.steps at your steps files so the editor learns the vocabulary of your contracts.

Story

Alice fixes the scheduler with the contract running in watch mode. Red, red, green — the failed-card scenario passes, the passed-card scenario still passes. She commits the feature file and the fix in one change; the reviewer reads Nadia's sentence, then the diff that honors it.

The machinery is in place. The next two chapters widen what a contract can say: structured data, other languages, and prose.

Reference: epure-vitest, options-type

05

Tables and other tongues

Some rules are not sentences. The school's interval ladder — how far a card jumps after each pass — is a table in Nadia's handbook, and forcing it into prose would make the contract less readable than the code. Gherkin has tables for exactly this, and Background to state a shared situation once:

Contract
Feature: The interval ladder

  Background:
    Given a deck with cards
      | name  | interval | dueIn |
      | gato  | 2        | 0     |
      | perro | 4        | 0     |
      | luz   | 16       | 3     |

  Scenario: Passing climbs the ladder
    When I review "gato" and pass
    Then the deck is
      | name  | interval | dueIn |
      | gato  | 4        | 4     |
      | perro | 4        | 0     |
      | luz   | 16       | 3     |

A table arrives at the step as raw rows of strings — the last parameter. Three helpers shape it: toRecords reads the header row and returns one record per line, toStrings and toNumbers flatten a single column into a list.

Example
import { Given, toRecords } from "@epure/vitest";

Given("a deck with cards", ({ When, Then }, rows) => {
  const deck = makeDeck(toRecords(rows));

  When("I review {string} and pass", (n: string) => deck.review(n, "pass"));
  Then("the deck is", (expected) => {
    expect(deck.snapshot()).toEqual(toRecords(expected));
  });
});
open EpureVitest

given("a deck with cards", ({step}, rows) => {
  let deck = Deck.make(toRecords(rows))

  step("I review {string} and pass", n => deck.review(n, Pass))
  step("the deck is", expected => {
    expect(deck.snapshot()).toEqual(toRecords(expected))
  })
})

The Then the deck is assertion compares the whole table: the contract states not only that gato moved but that nothing else did. Tables make completeness cheap to say.

The contract speaks the owner's language

Nadia thinks about scheduling in French, and there is no reason her contract shouldn't. Gherkin ships with some forty languages; a # language: header switches the keywords:

Contract
# language: fr
Fonctionnalité: Programmation des révisions

  Scénario: Une carte échouée revient le jour même
    Soit une carte "gato" avec un intervalle de 8 jours
    Quand je révise "gato" et j'échoue
    Alors "gato" est programmée dans 0 jours

The steps file cooperates without a single option, because binder names were never keywords — the context proxy hands out whatever names you destructure:

Example
Soit("une carte {string} avec un intervalle de {number} jours", ({ Quand, Alors }, nom: string, jours: number) => {
  const scheduler = makeScheduler();
  scheduler.add(nom, { interval: jours });

  Quand("je révise {string} et j'échoue", (n: string) => scheduler.review(n, "fail"));
  Alors("{string} est programmée dans {number} jours", (n: string, j: number) => {
    expect(scheduler.dueIn(n)).toBe(j);
  });
});
given("une carte {string} avec un intervalle de {number} jours", ({step}, nom: string) => {
  let scheduler = Scheduler.make()
  scheduler.add(nom, ~interval=8.0)

  step("je révise {string} et j'échoue", n => scheduler.review(n, Fail))
  step("{string} est programmée dans {number} jours", (n, j: float) => {
    expect(scheduler.dueIn(n)).toBe(j)
  })
})
Story

Nadia writes the next contract herself, in French, in the pull request. Alice's review is one comment long: "le scénario trois contredit le deux — lequel gagne ?" The argument happens in the contract, before the code, in the language the rules were born in.

Pro tip

A contract's language is a signing decision, not a style choice. Pick the language of the person who owns the rules — translation is exactly the drift the tool exists to remove.

Reference: to-records, to-strings, to-numbers

06

Contracts in the prose

A feature file states rules; it does not explain them. The why — the pedagogy behind the interval ladder, the history of the same-day rule — lives in prose, and prose drifts for the same reason specs do: nothing fails when it goes stale.

So @epure/vitest reads Markdown. Any .md or .mdx file in test.include is scanned for gherkin code fences, and the fences are compiled into one suite exactly as a .feature file would be — same steps resolution (handbook.md pairs with handbook.md.ts), same reporting, same watch mode. The prose between the fences is ignored by the runner and precious to the reader:

Contract
Feature: Review scheduling

  Background:
    Given a card "gato" with interval 8 days

Further down the same document, after two paragraphs explaining why a lapse must not punish the learner:

Contract
Scenario: A failed card returns the same day
  When I review "gato" and fail
  Then "gato" is scheduled 0 days out
  And the interval of "gato" is 1 day

Fences accumulate into a single feature: a Background in the first block governs scenarios in later ones, so the document reads as one argument and runs as one suite.

The handbook that cannot lie

This inverts the usual relationship between documentation and tests. The scheduling page of the school's handbook is no longer about the system — it is part of the system's test run. If a rewrite of the scheduler breaks a promise made in the handbook, CI fails on the handbook. The document Nadia shows new teachers is guaranteed current, not by discipline, but by the same mechanism that guards the code.

The épure world has a name for this: spec-driven development. Draft the change as prose with embedded scenarios — the reasoning and the rules in one reviewable document — and hand it to the machine. The prose anchors the implementation prompts; the fences grade the result. When the window closes, the design document did not go stale in a wiki: it became the regression suite.

Story

Alice moves the scheduling rules into scheduling.md: Nadia's reasoning as prose, each rule as a fence beneath the paragraph that justifies it. The old PDF goes in the bin. When a teacher asks "what happens when a student fails a mature card?", Nadia sends a link to a page that ran green twenty minutes ago.

Pro tip

Keep one concern per document, exactly as you would for a feature file. A handbook.md that accumulates every rule of the school becomes the same unreviewable blob the PDF was — the fences make prose testable, not infinite.

One audience remains: the ReScript codebase underneath the contracts. It gets the whole of Vitest in its own language — next chapter.

07

Vitest in ReScript

The second thing @epure/vitest does looks unrelated until you build an épure application: complete ReScript bindings for Vitest. The business floor of such an application is pure ReScript functions; the contracts sit above them, but the functions themselves deserve unit tests in the language they are written in. Without bindings, a ReScript team writes its tests in TypeScript — a translation layer at exactly the boundary the method tries to keep clean.

open EpureVitest and the whole runner is there. The former VitestBdd module remains available as a deprecated alias and produces a compiler/editor warning:

Example
// Scheduler_test.res
open EpureVitest

describe("Scheduler", () => {
  it("doubles the interval on pass", () => {
    let s = Scheduler.make()
    s.add("gato", ~interval=2.0)
    s.review("gato", Pass)
    expect(s.get("gato").interval).toBe(4.0)
  })

  it("loads the deck", async () => {
    await expect(Deck.fetch("spanish")).resolves.toHaveLength(3)
  })
})

Failures point at the .res line — source maps survive the compiler — and describe, test, it and bench come with Skip, Only and Todo modules for the modes, plus hooks for suites that manage a resource.

Assertions that know their type

expect returns a typed record of matchers: expect(5.0) offers float comparisons, expect([1, 2]) offers array matchers, and toBe demands the same type it was given. A matcher misuse that would fail at runtime in JavaScript does not compile here.

The statics of JavaScript's expect — a function that is also an object — cannot be one binding in ReScript, so they live under expected: soft, poll, assertion counting, and the asymmetric matchers for use inside comparisons:

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

One binding is quietly better than the original. resolves and rejects return promise-based assertions, and in JavaScript forgetting to await one is a classic silent bug — the test passes while the assertion floats away. In ReScript, an unawaited promise followed by more code is a type error. The language enforces what Vitest's lint rule only suggests.

Pro tip

Steps files are ReScript too — SchedulingSteps.res next to Scheduling.feature, resolved by the Steps suffix convention. Contract steps and unit tests share the same open EpureVitest, the same matchers, the same watch loop.

Story

Alice's scheduler is four pure functions in Scheduler.res. The contract holds the behavior Nadia signed; the unit tests hold the edge cases Nadia never thought to ask about — leap days, an empty deck, an interval already at the ceiling. Two nets, one runner, zero translation.

Reference: expect, expected, assertions-type, describe, test, before-each

08

Onward

Step back and look at what Alice and Nadia have now. The rules of review scheduling exist once, in a file both of them can read, and Vite translates that file into a test suite in memory. There is no second authored or generated test file that can drift from it silently. When the rules change, the contract changes first, in review, in Nadia's language — and the code follows because the build will not go green until it does.

The mental model compresses well:

  • One artifact. The feature file business signs is the file Vitest runs. The contract and the suite cannot drift apart because they are not two things.
  • Steps are closures. Given builds a private world; When and Then remember it. No World object, no registry — and concurrency for free, because nothing is shared.
  • The runner is Vitest. Watch mode, filtering, reporting, CI: contracts inherit all of it through one plugin line.
  • Tables and tongues. Structured rules stay tables; the contract speaks the language of whoever owns the rules.
  • Prose can be contract. Gherkin fences make a design document part of the test run — the handbook that cannot lie.
  • ReScript is first-class. Steps, unit tests and the full Vitest surface in one typed language, with the await-a-promise bug demoted to a compile error.

The sheet set, complete

This guide closes a trilogy. tilia is the foundation sheet: application state in the domain's shape, observed directly. @tilia/query is the data sheet: the sap — remote collections held through winter, flowing again at thaw. @epure/vitest is the proof sheet: the compas that steps the drawing off against the work. Foundation, data, proof — the toolset of épure, the method that opens a window on a validated need and refuses to close it until every signed scenario runs green.

The three tools also vouch for each other. The offline behaviors the @tilia/query guide narrated — the tunnel edits, the restart replay — are pinned by a Gherkin specification that runs under @epure/vitest. The tools are built the way the method says software should be built; the drawing governed their own structure first.

Where to go from here

The API reference is the flat, complete surface — the plugin, the step definitions, the table helpers, and the Vitest bindings, each with signatures in both languages. This guide chose the readable path; the reference has the precise one.

If you arrived here first: the tilia guide explains the reactivity that carries application state to the screen, and the @tilia/query guide explains how remote data joins it. Read this trilogy in any order; the tools compose in every one.

Story

Friday. Nadia opens the window's closing review with the handbook page — every fence green. The auditor asks how she knows the app does what the document says. She reruns the suite instead of answering. What was drawn is what was built; the compas agrees, clause by clause.