Skip to content

Instantly share code, notes, and snippets.

@L4Ph
Created August 28, 2026 13:41
Show Gist options
  • Select an option

  • Save L4Ph/e170074da99b50062fd45bdc18dbd3dc to your computer and use it in GitHub Desktop.

Select an option

Save L4Ph/e170074da99b50062fd45bdc18dbd3dc to your computer and use it in GitHub Desktop.
VitestでBDDやるやつ
import { describe, test } from "vitest";
import { withMockBan } from "./mock-ban.ts";
export { MOCK_BAN_MESSAGE, installMockBan } from "./mock-ban.ts";
/* ------------------------------------------------------------------ *
* Types
* ------------------------------------------------------------------ */
/**
* Runtime world bag. Caller-facing steps see accumulated `W` via Arrange/Merge;
* at runtime contributions are Object.assign'd onto a ScenarioWorld instance.
*/
class ScenarioWorld {
// Keys are supplied by step contributions; this class is the named owner type.
}
/**
* Step contributions. Only keyed objects can extend the world; arrays and
* primitives are rejected at the type level (the runtime merge would otherwise
* silently drop them), while nothing-returning steps (void/undefined/null)
* leave the world unchanged.
*/
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- step contributions are an open keyed bag by design
type Contribution = Record<string, unknown> | void | null | undefined;
type Merge<W, T> = T extends void | null | undefined ? W : W & T;
type StepKind = "Given" | "When" | "Then";
type StepFn = (world: ScenarioWorld) => ScenarioWorld | void | Promise<ScenarioWorld | void>;
interface Step {
kind: StepKind;
text: string;
/** Steps after the first of the same kind render as "And". */
conjunction: boolean;
fn: StepFn;
}
/** Given phase — arrange preconditions. */
export interface Arrange<W> {
given<T extends Contribution>(
text: string,
fn: (world: W) => T | Promise<T>,
): Arrange<Merge<W, Awaited<T>>>;
and<T extends Contribution>(
text: string,
fn: (world: W) => T | Promise<T>,
): Arrange<Merge<W, Awaited<T>>>;
when<T extends Contribution>(
text: string,
fn: (world: W) => T | Promise<T>,
): Act<Merge<W, Awaited<T>>>;
}
/** When phase — invoke the behavior once. */
export interface Act<W> {
and<T extends Contribution>(
text: string,
fn: (world: W) => T | Promise<T>,
): Act<Merge<W, Awaited<T>>>;
then(text: string, fn: (world: W) => void | Promise<void>): Assert<W>;
}
/** Then phase — assert observable outcomes only. */
export interface Assert<W> {
and(text: string, fn: (world: W) => void | Promise<void>): Assert<W>;
}
export type ScenarioBody = (chain: Arrange<Record<string, never>>) => void;
/* ------------------------------------------------------------------ *
* Chain builders
* ------------------------------------------------------------------ */
function push(steps: Step[], kind: StepKind, text: string, fn: StepFn): void {
steps.push({
kind,
text,
conjunction: steps.some((s) => s.kind === kind),
fn,
});
}
/**
* Build a fluent chain. Generics on Arrange/Act refine the *caller* API;
* the runtime object always returns the same chain instance.
*/
function arrange(steps: Step[]): Arrange<Record<string, never>> {
const self = {
given(text: string, fn: StepFn) {
push(steps, "Given", text, fn);
return self;
},
and(text: string, fn: StepFn) {
push(steps, "Given", text, fn);
return self;
},
when(text: string, fn: StepFn) {
push(steps, "When", text, fn);
return act(steps);
},
};
// SAFETY: self is the Arrange chain; Merge accumulation is erased at runtime by design.
return self as Arrange<Record<string, never>>;
}
function act(steps: Step[]): Act<Record<string, never>> {
const self = {
and(text: string, fn: StepFn) {
push(steps, "When", text, fn);
return self;
},
// oxlint-disable-next-line unicorn/no-thenable -- GWT DSL step name required by the public API contract
then(text: string, fn: StepFn) {
push(steps, "Then", text, fn);
return assertStage(steps);
},
};
// SAFETY: self is the Act chain; Merge accumulation is erased at runtime by design.
return self as Act<Record<string, never>>;
}
function assertStage(steps: Step[]): Assert<Record<string, never>> {
const self = {
and(text: string, fn: StepFn) {
push(steps, "Then", text, fn);
return self;
},
};
// SAFETY: self is the Assert chain; world type is carried only at the call site.
return self as Assert<Record<string, never>>;
}
function collect(body: ScenarioBody): Step[] {
const steps: Step[] = [];
body(arrange(steps));
return steps;
}
/* ------------------------------------------------------------------ *
* Runner
* ------------------------------------------------------------------ */
function label(step: Step): string {
return `${step.conjunction ? "And" : step.kind} ${step.text}`;
}
function decorate(error: Error, step: Step, done: Step[]): never {
const trail = done.map((s) => ` ✓ ${label(s)}`).join("\n");
const header = `${trail}${trail ? "\n" : ""} ✗ ${label(step)}`;
error.message = `\n${header}\n\n${error.message}`;
throw error;
}
function mergeContribution(world: ScenarioWorld, result: ScenarioWorld | void): void {
if (result === undefined || result === null) return;
if (Array.isArray(result)) return;
Object.assign(world, result);
}
async function runSteps(steps: Step[]): Promise<void> {
const world = new ScenarioWorld();
const done: Step[] = [];
for (const step of steps) {
let result: ScenarioWorld | void;
try {
result = await step.fn(world);
} catch (caught) {
if (caught instanceof Error) decorate(caught, step, done);
throw caught;
}
mergeContribution(world, result);
done.push(step);
}
}
/* ------------------------------------------------------------------ *
* Public API
* ------------------------------------------------------------------ */
/** describe wrapper. One capability = one feature. */
export function feature(name: string, body: () => void): void {
// oxlint-disable-next-line vitest/valid-describe-callback -- forwards the caller-provided describe body unchanged
describe(`Feature: ${name}`, body);
}
type ScenarioOptions = { timeout?: number };
type TestRunner = (name: string, fn: () => void | Promise<void>, timeout?: number) => void;
type OutlineTitle<C> = string | ((example: C, index: number) => string);
function outlineTitle<C>(name: OutlineTitle<C>, example: C, index: number): string {
if (name instanceof Function) return name(example, index);
return `${name} [${index + 1}]`;
}
export interface ScenarioFn<W> {
(name: string, body: (chain: Arrange<W>) => void, options?: ScenarioOptions): void;
only(name: string, body: (chain: Arrange<W>) => void, options?: ScenarioOptions): void;
skip(name: string, body: (chain: Arrange<W>) => void, options?: ScenarioOptions): void;
/** Scenario Outline: one scenario per examples row. */
outline<C>(
name: OutlineTitle<C>,
examples: readonly C[],
body: (chain: Arrange<W>, example: C) => void,
options?: ScenarioOptions,
): void;
}
function makeScenario<W>(prelude: Step[]): ScenarioFn<W> {
const define = (
runner: TestRunner,
name: string,
body: (chain: Arrange<W>) => void,
options?: ScenarioOptions,
requireCompleteChain = true,
) => {
// SAFETY: body is the same chain shape; W is phantom for the caller-facing Arrange.
const steps = [...prelude, ...collect(body as ScenarioBody)];
if (requireCompleteChain) {
const kinds = new Set(steps.map((s) => s.kind));
if (!kinds.has("When")) throw new Error(`Scenario "${name}": when() is required`);
if (!kinds.has("Then")) throw new Error(`Scenario "${name}": then() is required`);
}
runner(`Scenario: ${name}`, () => withMockBan(() => runSteps(steps)), options?.timeout);
};
return Object.assign(
(name: string, body: (chain: Arrange<W>) => void, options?: ScenarioOptions) =>
define(test, name, body, options),
{
only: (name: string, body: (chain: Arrange<W>) => void, options?: ScenarioOptions) =>
define(test.only, name, body, options),
skip: (name: string, body: (chain: Arrange<W>) => void, options?: ScenarioOptions) =>
// Incomplete WIP scenarios must still collect under test.skip.
define(test.skip, name, body, options, false),
outline<C>(
name: OutlineTitle<C>,
examples: readonly C[],
body: (chain: Arrange<W>, example: C) => void,
options?: ScenarioOptions,
) {
if (examples.length === 0) {
const label = name instanceof Function ? name.name || "outline" : name;
throw new Error(`Scenario outline "${label}": examples must not be empty`);
}
examples.forEach((example, index) => {
define(test, outlineTitle(name, example, index), ($) => body($, example), options);
});
},
},
);
}
/**
* Run a scenario body under the mock ban without registering a Vitest test.
* Useful for asserting failure paths (step trails, mock bans) from plain tests.
*/
export async function runScenarioBody(body: ScenarioBody): Promise<void> {
const steps = collect(body);
const kinds = new Set(steps.map((s) => s.kind));
if (!kinds.has("When")) throw new Error("Scenario body: when() is required");
if (!kinds.has("Then")) throw new Error("Scenario body: then() is required");
await withMockBan(() => runSteps(steps));
}
/** Scenario with no shared background. */
export const scenario: ScenarioFn<Record<string, never>> = makeScenario([]);
/**
* Shared Given steps for every scenario in a feature.
* Steps re-run per scenario — always return fresh instances.
*
* const scenario = background(($) => $.given('fixed clock', () => ({ clock: fixedClock() })));
*/
export function background<W>(
body: ($: Arrange<Record<string, never>>) => Arrange<W>,
): ScenarioFn<W> {
const steps: Step[] = [];
body(arrange(steps));
return makeScenario<W>(steps);
}
import { vi } from "vitest";
export const MOCK_BAN_MESSAGE =
"Detroit-style tests ban vi.fn() / vi.spyOn() / vi.mock() / vi.doMock(). " +
"Replace shared dependencies (DB, clock, IDs, external APIs) with fakes that have real behavior, " +
"and use real collaborators for everything else.";
const MOCK_METHODS = ["mock", "doMock", "fn", "spyOn"] as const;
type MockMethod = (typeof MOCK_METHODS)[number];
let banDepth = 0;
const originals = new Map<MockMethod, PropertyDescriptor>();
function rejectMockApi(): never {
throw new Error(MOCK_BAN_MESSAGE);
}
/** Patch vi mock helpers to throw. Returns a restore function (refcount-safe). */
export function installMockBan(): () => void {
if (banDepth === 0) {
originals.clear();
for (const method of MOCK_METHODS) {
const descriptor = Object.getOwnPropertyDescriptor(vi, method);
if (descriptor) originals.set(method, descriptor);
Object.defineProperty(vi, method, {
configurable: true,
enumerable: true,
writable: true,
value: rejectMockApi,
});
}
}
banDepth += 1;
let restored = false;
return () => {
if (restored) return;
restored = true;
banDepth -= 1;
if (banDepth === 0) {
for (const method of MOCK_METHODS) {
const descriptor = originals.get(method);
if (descriptor) Object.defineProperty(vi, method, descriptor);
}
originals.clear();
}
};
}
export async function withMockBan<T>(run: () => Promise<T>): Promise<T> {
const restore = installMockBan();
try {
return await run();
} finally {
restore();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment