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.
Guide·v1.x
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 in prose, and writing Vitest in ReScript. Read it from top to bottom once. When you need to look something up later, use the API reference instead — it is flat, complete, and designed for reference.
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 out 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 containing the rules. 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 directly to Vitest as a test suite. It is neither converted into nor mirrored by another file: the file the 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 working together, then signed by the people who own the need. From that moment, the contract governs the whole structure: the AI implements against it, the senior reviewer assesses the work against it, and @epure/vitest makes that governance 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.
Each chapter covers 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 are deciding 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.
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:
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 dayThat second scenario is the one the shipped bug violated — now it is 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), continues with When (the action), and asserts with Then. And and But continue whichever kind of step came before — they improve readability without changing 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 are covered in chapter five.
That is the whole grammar. Its small size makes it accessible: Nadia reads this file with no training, catches the wrong rule before implementation, and what she approves is — verbatim — what runs.
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. There is no code yet that needs fixing.
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: for scheduling.feature, the resolver 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.
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 →
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:
// 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)", ({ step }, name: string, days: number) => {
const scheduler = makeScheduler();
const card = scheduler.add(name, { interval: days });
step("I review {string} and pass", (n: string) => scheduler.review(n, "pass"));
step("I review {string} and fail", (n: string) => scheduler.review(n, "fail"));
step("{string} is scheduled {number} day(s) out", (n: string, d: number) => {
expect(scheduler.dueIn(n)).toBe(d);
});
step("the interval of {string} is {number} day(s)", (n: string, d: number) => {
expect(scheduler.get(n).interval).toBe(d);
});
});// SchedulingSteps.res
open EpureVitest
given1("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. No one has to serialize access to global state.
One word in the code. The keywords belong to the feature file: When, Then, And, But — or Quand and Alors. Matching is by pattern text, so the code needs only one step binder: step, the same in TypeScript and ReScript. The handle's other field is test, the running Vitest test, for test.onTestFinished teardown.
Async is ordinary. Builders and steps may be async; the runner awaits each in turn.
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.
In ReScript, given binds a pattern with no captures; given1 and given2 bind one and two. In practice a Given with three parameters is usually a scenario trying to hide its setup.
Alice deletes the fix she had started writing. The failed-card scenario runs red — expected 1, received 0 — and points to 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.
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 setup is one import and one line in test.include:
// 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, so there is nothing to regenerate or add to .gitignore. The feature file appears in the test tree under its own name, with each scenario represented as a test and each step as a line in the report.
Everything Vitest gives, contracts get
Because the contract is a first-class suite, every Vitest capability applies without extra ceremony. Watch mode: save the feature file or the steps file and the affected scenarios rerun — Alice keeps the contract open in one split and the scheduler in the other, and the feedback loop is the same fast 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 no shared state to contend over. If a contract drives something genuinely shared (one database, one port), set concurrent: false in the options rather than hoping concurrency will remain safe.
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 requirement that every scenario runs green and the engineer's regression net.
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.
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 →
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:
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 in the step as raw rows of strings, passed as the last parameter. Three helpers shape it: toRecords reads the header row and returns one record per data row, while toStrings and toNumbers flatten a single column into a list.
import { Given, toRecords } from "@epure/vitest";
Given("a deck with cards", ({ step }, rows) => {
const deck = makeDeck(toRecords(rows));
step("I review {string} and pass", (n: string) => deck.review(n, "pass"));
step("the deck is", (expected) => {
expect(deck.snapshot()).toEqual(toRecords(expected));
});
});open EpureVitest
given1("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 easy to express.
The contract speaks the owner's language
Nadia thinks about scheduling in French, and there is no reason her contract should not be in French too. Gherkin ships with some forty languages; a # language: header switches the keywords:
# 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 joursThe steps file works without any configuration because the keywords live in the contract, not in the code — step binds a French sentence as readily as an English one:
Soit("une carte {string} avec un intervalle de {number} jours", ({ step }, nom: string, jours: number) => {
const scheduler = makeScheduler();
scheduler.add(nom, { interval: jours });
step("je révise {string} et j'échoue", (n: string) => scheduler.review(n, "fail"));
step("{string} est programmée dans {number} jours", (n: string, j: number) => {
expect(scheduler.dueIn(n)).toBe(j);
});
});given1("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)
})
})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.
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 →
Contracts in 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. 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:
Feature: Review scheduling
Background:
Given a card "gato" with interval 8 daysFurther down the same document, after two paragraphs explaining why a lapse must not punish the learner:
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 dayFences 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 when the handbook's scenarios fail. The document Nadia shows new teachers is guaranteed to be 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.
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 whose tests ran green twenty minutes ago.
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; they do not make an endless handbook reviewable.
One layer remains: the ReScript codebase beneath the contracts. It gets the whole of Vitest in its own language in the next chapter.
Vitest in ReScript
The second thing @epure/vitest does may seem unrelated until you build an épure application: it provides complete ReScript bindings for Vitest. The business floor of such an application consists of pure ReScript functions; the contracts sit above them, but the functions themselves deserve unit tests in the language in which they are written. Without bindings, a ReScript team writes its tests in TypeScript — a translation layer at exactly the boundary the method tries to keep clean.
With open EpureVitest, the whole runner is available. The former VitestBdd
module remains available as a deprecated alias and produces warnings in the
compiler and editor:
// 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 to 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 static properties of JavaScript's expect — a function that is also an object — cannot share one binding in ReScript, so they live under expected: soft, poll, assertion counting, and the asymmetric matchers used inside comparisons:
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.
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.
Alice's scheduler is four pure functions in Scheduler.res. The contract holds the behavior Nadia signed off on; 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 →
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 compiles 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 the business signs is the file Vitest runs. The contract and the suite cannot drift apart because they are not two things.
- Steps are closures.
Givenbuilds a private world;WhenandThenremember 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 a 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 unawaited-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; their own structure was governed by the drawing 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. The guides can be read in any order because the tools work together.
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.