Created
August 28, 2026 13:41
-
-
Save L4Ph/e170074da99b50062fd45bdc18dbd3dc to your computer and use it in GitHub Desktop.
VitestでBDDやるやつ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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