@nagatatz/rescript-vitest
v0.2.0
Published
Type-safe ReScript bindings for Vitest — describe/test/expect matchers, vi mocks, spies, snapshots, fake timers.
Maintainers
Readme
@nagatatz/rescript-vitest
Type-safe ReScript bindings for Vitest.
The bindings are faithful: matchers are side-effecting and throw on failure,
exactly like Vitest itself. The expect(value) wrapper carries the type of the
value under test, so matchers such as toBe stay honest at compile time.
- ✅
describe/test/it(+.only/.skip/.todo/.each/.concurrent/.sequential/.shuffle/.skipIf/.runIf/.fails/.for) - ✅ Lifecycle hooks (
beforeEach/afterEach/beforeAll/afterAll/aroundEach/aroundAll/onTestFailed/onTestFinished, sync & async) - ✅ The full
expectmatcher set (equality, numbers, strings, collections, objects, type/predicate, exceptions, snapshots — inline and file, mock call/return/resolve matchers) - ✅ Asymmetric matchers and negations (
Expect.anything/arrayContaining/objectContaining/ … andExpect.Not.*), plus assertion guards (assertions/hasAssertions/soft/poll) - ✅ Negation (
not_) and async assertions (resolves/rejects) - ✅
Vi— mock functions, spies (incl. getter/setter), module mocking, global/env stubs, and fake timers (sync & async) withwaitFor/waitUntil - ✅
VitestConfig— minimalvitest/configbindings (defineConfig/mergeConfig/defineProject+ the commontestfields) - ✅ Closed-set arguments are polymorphic variants, rejecting invalid values at compile time (
toBeTypeOf(#string),spyOnAccessor(…, #get),coverage.provider: #v8)
Why another binding?
rescript-vitest (cometkim) and
@greenfinity/rescript-vitest
both target Vitest 2/3 and pin the dependency tree to Vite 5. Vitest 4.1+
requires vite/module-runner (Vite 6+), so those bindings block the upgrade.
This package targets Vitest 4 (Vite 6/7) directly.
Requirements
| Tool | Version |
|------|---------|
| ReScript (rescript) | ^12.0.0-0 (12.x, prereleases allowed) |
| @rescript/runtime | ^12.0.0-0 (same major as ReScript) |
| Vitest | ^4.0.0 |
| Vite | ^6 or ^7 (Vitest 4 peer) |
rescript, @rescript/runtime and vitest are peer dependencies — install
them alongside the bindings.
Install
pnpm add -D @nagatatz/rescript-vitest rescript @rescript/runtime vitest viteAdd the package to your rescript.json dependencies so ReScript compiles the
bindings:
{
"dependencies": ["@nagatatz/rescript-vitest"]
}Point Vitest at the compiled test files (*.res.js):
// vitest.config.js
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['__tests__/**/*_test.res.js'],
},
})Usage
open Vitest
describe("Math", () => {
test("adds", () => {
expect(1 + 1)->toBe(2)
})
test("negation", () => {
expect([1, 2])->not_->toContain(3)
})
testAsync("works with promises", async () => {
await expect(Promise.resolve(42))->resolves->Async.toBe(42)
})
})Mocks, spies and timers
open Vitest
test("counts calls", () => {
let mock = Vi.fn1()
let fn = mock->Vi.MockFn.asFn // use the mock where a function is expected
fn("a")->ignore
mock->Vi.MockFn.asAssertion->toHaveBeenCalledOnce
})
test("fake timers", () => {
Vi.useFakeTimers()
// ... schedule a timer ...
Vi.advanceTimersByTime(1000)
Vi.useRealTimers()
})API cheat sheet
Test structure (Vitest)
describe, describeAsync, describeOnly, describeSkip, describeEach (+ …2 / …3 for tuple cases),
describeTodo, describeConcurrent, describeSequential, describeShuffle,
describeSkipIf, describeRunIf, describeFor,
test, testAsync, testOnly, testOnlyAsync, testSkip, testTodo, testConcurrent,
testEach (+ …2 / …3), testFor, testSkipIf, testSkipIfAsync, testRunIf, testRunIfAsync,
testFails, testFailsAsync, testSequential, testSequentialAsync,
it, itAsync, itOnly, itSkip, itTodo, itConcurrent, itEach (+ …2 / …3), itFails,
itSequential, itSkipIf, itRunIf.
Lifecycle
beforeAll, afterAll, beforeEach, afterEach, the suite/test wrappers aroundAll, aroundEach (each receives a runSuite / runTest thunk to await), plus the per-test hooks onTestFailed, onTestFinished (the before*/after*/onTest* hooks each have an …Async variant).
expect matchers
- Equality:
toBe,toEqual,toStrictEqual - Truthiness:
toBeTruthy,toBeFalsy,toBeNull,toBeUndefined,toBeDefined,toBeNaN - Numbers:
toBeGreaterThan,toBeGreaterThanOrEqual,toBeLessThan,toBeLessThanOrEqual,toBeCloseTo,toBeCloseToWithDigits - Strings:
toMatch,toMatchRegExp,toContainString - Collections:
toContain,toContainEqual,toHaveLength - Objects:
toMatchObject,toHaveProperty,toHavePropertyValue - Type & predicate:
toBeTypeOf,toBeInstanceOf,toBeOneOf,toSatisfy - Exceptions:
toThrow,toThrowWithMessage,toThrowRegExp - Snapshots:
toMatchSnapshot,toMatchSnapshotWithName,toMatchInlineSnapshot,toMatchFileSnapshot,toThrowErrorMatchingSnapshot,toThrowErrorMatchingInlineSnapshot - Mocks (calls):
toHaveBeenCalled,toHaveBeenCalledOnce,toHaveBeenCalledTimes,toHaveBeenCalledWith(+…With2),toHaveBeenLastCalledWith(+…With2),toHaveBeenNthCalledWith(+…With2),toHaveBeenCalledExactlyOnceWith(+…With2) - Mocks (returns):
toHaveReturned,toHaveReturnedTimes,toHaveReturnedWith,toHaveLastReturnedWith,toHaveNthReturnedWith - Mocks (resolves):
toHaveResolved,toHaveResolvedTimes,toHaveResolvedWith,toHaveLastResolvedWith,toHaveNthResolvedWith - Asymmetric (
Expectmodule):anything,any,arrayContaining,objectContaining,stringContaining,stringMatching(+…RegExp),closeTo(+…WithPrecision) — embed in the expected position oftoEqual/toMatchObject/toHaveBeenCalledWith; negated forms live inExpect.Not - Guards & special (
Expectmodule):assertions,hasAssertions,soft,poll,unreachable(+…WithMessage) - Modifiers:
not_,resolves/rejects(+ theAsyncmatcher module)
Not yet bound
test.extend (fixtures) and expect.extend (custom matchers) are intentionally
left unbound: their JavaScript shapes — a fixtures object injected via use
callbacks, and matchers added dynamically onto every assertion — cannot be
expressed faithfully or type-safely in ReScript without per-matcher manual
bindings. Use ReScript helper functions instead of fixtures, and a dedicated
@send external if you must call a project-specific custom matcher.
Vi
- Create:
fn,fnWith,fn0,fn1,fn2,spyOn,spyOnAccessor(#get/#set),spyOnGetter,spyOnSetter MockFn:asFn,asAssertion,calls(one argument list per call),results({type_, value}records),mockClear,mockReset,mockRestore,mockImplementation,mockImplementationOnce,mockReturnValue,mockReturnValueOnce,mockResolvedValue,mockResolvedValueOnce,mockRejectedValue,mockRejectedValueOnce,mockReturnThis,getMockName,mockName,getMockImplementation,withImplementation- Inspection / hoisting:
mocked,isMockFunction,hoisted - Modules:
mock,mockWithFactory,unmock,doMock,doUnmock,resetModules,importActual,importMock,mockObject,dynamicImportSettled - Global / env stubs:
stubGlobal,stubEnv,unstubAllGlobals,unstubAllEnvs - Global state:
clearAllMocks,resetAllMocks,restoreAllMocks - Timers:
useFakeTimers(+useFakeTimersWithforFakeTimerInstallOpts),useRealTimers,runAllTimers,runAllTicks,runOnlyPendingTimers,advanceTimersByTime,advanceTimersToNextTimer,setSystemTime(+…Ms),clearAllTimers - Async timers:
advanceTimersByTimeAsync,runAllTimersAsync,runOnlyPendingTimersAsync,advanceTimersToNextTimerAsync,advanceTimersToNextFrame - Timer inspection:
isFakeTimers,getTimerCount,getMockedSystemTime,getRealSystemTime,setTimerTickMode(+…WithInterval) - Waiting:
waitFor,waitUntil(each with a…Withvariant taking{interval?, timeout?})
VitestConfig (vitest/config)
Minimal config-side bindings: defineConfig, defineConfigFn (function form
receiving {mode, command}), mergeConfig, defineProject. Configs are typed
with optional-record types covering the common test fields — globals,
environment, include_, exclude, setupFiles, coverage, pool,
testTimeout, hookTimeout, reporters, watch, projects — and coverage
(provider, enabled, reporter, include_, exclude). (include is a
ReScript keyword, so the field is named include_ and maps to JS "include".)
coverage.provider is a polymorphic variant (#istanbul / #v8 / #custom);
pool and environment stay string because Vitest treats them as extensible
unions, so custom pools / environment packages remain expressible.
Scope (intentional): Vite-level config (plugins, resolve, server,
build) and the long tail of test options are not bound — write those in a
plain JS config file. Config objects are write-once data with a large, churning
surface, so full coverage is not worth the maintenance cost.
ReScript does not emit export default, so wire the typed config into the real
vitest.config through a thin JS shim:
// MyConfig.res
let config = VitestConfig.defineConfig({
test: {globals: true, environment: "node", include_: ["__tests__/**/*_test.res.js"]},
})// vitest.config.js
import { config } from "./MyConfig.res.js"
export default configDevelopment
pnpm install
pnpm build # compile ReScript bindings + tests
pnpm test # run the dogfood test suite under Vitest 4
pnpm format # format hand-written JS/JSON with oxfmt
pnpm lint # lint hand-written JS with oxlintNon-ReScript hand-written files (vitest.config.js, JSON configs) are formatted
with oxfmt and linted with oxlint; generated
*.res.js output is excluded via .gitignore. A pre-commit hook (activated by
the prepare script setting core.hooksPath) runs format:check + lint.
Binding correctness is verified by compilation (the bindings type-check) and
the dogfood test suite (each binding is called against a real Vitest). Code
coverage is not used as a gate: ReScript externals are erased at compile time
and leave almost no instrumentable code, so statement/line coverage cannot detect
an untested binding. pnpm test:coverage remains available for local inspection.
