02-vitest-patterns
Effect-TS 4 Vitest patterns — it.effect, it.scoped, it.layer, @effect/vitest/utils assertions, ConfigProvider for env, and platform-node-shared for FileSystem
- License 许可
- MIT
- 最近更新
- 2026-05-31
- Stars
- ★ 0
- Forks
- 0
01 前言 Front Matter
- name
- 02-vitest-patterns
- license
- MIT
- description
- Effect-TS 4 Vitest patterns — it.effect, it.scoped, it.layer, @effect/vitest/utils assertions, ConfigProvider for env, and platform-node-shared for FileSystem
- compatibility
- opencode
03 SKILL.md 正文
Testing with @effect/vitest
Package: @effect/vitest
Version: @effect/vitest@4.0.0-beta.41
Canonical import pattern
import { describe, it } from "@effect/vitest";
import { assertSome, assertTrue, strictEqual } from "@effect/vitest/utils";
import { Effect, Layer } from "effect";
itanddescribe— always from@effect/vitest- assertions — always from
@effect/vitest/utils(Effect-aware, usesEqual.equals) - never import
it/describefrom"vitest" - never use
expectfrom"vitest"for Effect values — use@effect/vitest/utils
@effect/vitest/utils — full assertion API
import {
assertDefined, // assertDefined(value)
assertExitFailure, // assertExitFailure(exit, cause)
assertExitSuccess, // assertExitSuccess(exit, expected)
assertFailure, // assertFailure(result, expected)
assertFalse, // assertFalse(value)
assertInclude, // assertInclude(actual, expected)
assertInstanceOf, // assertInstanceOf(value, Constructor)
assertMatch, // assertMatch(actual, regExp)
assertNone, // assertNone(option)
assertSome, // assertSome(option, expected)
assertSuccess, // assertSuccess(result, expected)
assertTrue, // assertTrue(value)
assertUndefined, // assertUndefined(value)
deepStrictEqual, // deep equality via Equal.equals
fail, // fail("message")
strictEqual, // strictEqual(actual, expected)
} from "@effect/vitest/utils";
it.effect — basic Effect test
it.effect("processes item correctly", () =>
Effect.gen(function* () {
const svc = yield* MyService;
const result = yield* svc.process("input");
strictEqual(result, "expected");
}).pipe(Effect.provide(MyService.layerTest)),
);
it.effect with timeout
Pass timeout (ms) as the last argument:
it.effect(
"calls real API",
() =>
Effect.gen(function* () {
const result = yield* callRealApi();
assertTrue(result.length > 0);
}).pipe(Effect.provide(realLayer)),
30_000,
);
it.scoped — test needs a Scope (resources, temp files)
it.scoped("cleans up temp file", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* fs.makeTempFileScoped(); // auto-cleaned on scope close
yield* fs.writeFileString(path, "hello");
const content = yield* fs.readFileString(path);
strictEqual(content, "hello");
}).pipe(Effect.provide(NodeFileSystem.layer)),
);
it.layer — shared layer for all tests in a describe block
Provide a layer once; all it.effect / it.scoped inside share it:
import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
import * as NodePath from "@effect/platform-node-shared/NodePath";
const platformLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
describe("MyService", () => {
it.layer(platformLayer)((it) => {
it.effect("reads a file", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const content = yield* fs.readFileString("/tmp/test.txt");
assertDefined(content);
}),
);
});
});
it.effect.skipIf — conditional skip
it.effect.skipIf(!process.env["OPENROUTER_API_KEY"])(
"calls real OpenRouter",
() =>
Effect.gen(function* () {
const result = yield* LanguageModel.generateText({ prompt: "hello" });
assertTrue(result.text.length > 0);
}).pipe(Effect.provide(realLayer)),
30_000,
);
it.effect.each — parameterised tests
it.effect.each([
{ input: " Ada ", expected: "ada" },
{ input: " Lin ", expected: "lin" },
])("trims and lowercases $input", ({ input, expected }) =>
Effect.gen(function* () {
strictEqual(input.trim().toLowerCase(), expected);
}),
);
Loading env vars for integration tests — ConfigProvider
Do not use vitest.setup.ts or manual .env parsing. Use
ConfigProvider.fromUnknown(process.env) inside the test — this is the
idiomatic Effect way to load env:
import { ConfigProvider, Effect, Layer } from "effect";
import { FetchHttpClient } from "effect/unstable/http";
it.effect.skipIf(!process.env["OPENROUTER_API_KEY"])(
"calls real OpenRouter",
() =>
Effect.gen(function* () {
const layer = makeOpenRouterLayer("minimax/minimax-m2.5:free").pipe(Layer.provide(FetchHttpClient.layer));
const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
Effect.map((r) => r.text),
Effect.provide(layer),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(process.env))),
);
assertTrue(result.length > 0);
}),
30_000,
);
Vitest does not load .env automatically. The key must already be in
process.env when Vitest starts. Pass it via shell:
# inline
OPENROUTER_API_KEY=sk-... bun run test
# or source first
source .env && bun run test
Without the key, skipIf(!process.env["OPENROUTER_API_KEY"]) skips the test
cleanly — no failure.
FileSystem in tests — use @effect/platform-node-shared
Vitest runs under Node, not Bun. Never import BunFileSystem or the
@effect/platform-bun barrel in test files — it pulls in BunRedis which
requires bun:* native modules unavailable under Node.
Use subpath imports from @effect/platform-node-shared instead:
import * as NodeFileSystem from "@effect/platform-node-shared/NodeFileSystem";
import * as NodePath from "@effect/platform-node-shared/NodePath";
const testLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
@effect/platform-node-shared must be an explicit devDependency — it is
a transitive dep of @effect/platform-bun but Vite cannot resolve transitive
deps from .bun/ without a direct entry:
"devDependencies": {
"@effect/platform-node-shared": "4.0.0-beta.41"
}
The package has no barrel index.js — always use subpath imports:
@effect/platform-node-shared/NodeFileSystem@effect/platform-node-shared/NodePath@effect/platform-node-shared/NodeChildProcessSpawner
HTTP in tests — use FetchHttpClient
BunHttpClient is a re-export of FetchHttpClient with no bun:* deps, but
importing via the @effect/platform-bun barrel pulls in BunRedis. Use
FetchHttpClient directly:
import { FetchHttpClient } from "effect/unstable/http";
const layer = makeOpenRouterLayer("minimax/minimax-m2.5:free").pipe(Layer.provide(FetchHttpClient.layer));
Mock LanguageModel layer
import { LanguageModel } from "effect/unstable/ai";
const mockLmLayer = (responses: ReadonlyArray<string>): Layer.Layer<LanguageModel.LanguageModel> =>
Layer.effect(
LanguageModel.LanguageModel,
Effect.gen(function* () {
const idx = yield* Ref.make(0);
return {
generateText: (_options: unknown) =>
Effect.gen(function* () {
const i = yield* Ref.getAndUpdate(idx, (n) => n + 1);
const text = responses[Math.min(i, responses.length - 1)] ?? "";
return {
text,
usage: { inputTokens: 0, outputTokens: 0 },
toolCalls: [],
finishReason: "stop" as const,
};
}),
generateObject: () => Effect.die(new Error("not mocked")),
streamText: () => Effect.die(new Error("not mocked")),
} as unknown as LanguageModel.Service;
}),
);
File structure convention
import { describe, it } from "@effect/vitest"
import { strictEqual, assertTrue } from "@effect/vitest/utils"
import { Effect, Layer } from "effect"
// ─── Fixtures ────────────────────────────────────────────────────────────────
const fixture = { id: "test", value: 42 }
// ─── Mock helpers ─────────────────────────────────────────────────────────────
const mockLayer = Layer.succeed(MyService, { ... } as unknown as MyService)
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("MyService", () => {
it.effect("does the thing", () =>
Effect.gen(function* () {
const result = yield* doThing()
strictEqual(result, "expected")
}).pipe(Effect.provide(mockLayer)),
)
})
Linting Discipline
Never disable linter rules with // oxlint-disable-next-line or similar comments.
If a lint warning appears:
- Fix the underlying code issue (rename shadowed variables, remove unused imports, etc.)
- If the warning is a false positive, refactor the code to avoid triggering the rule
- Only as a last resort after team discussion, add a disable comment with a detailed explanation
Adding disable comments without fixing the root cause is technical debt and hides real issues.
04 同仓库更多 Skills
tmux-workflow
Development workflow with tmux — run dev servers, Storybook, and background processes in persistent sessions with live logs, while keeping OpenCode free for chat
04-anti-patterns
React anti-patterns to avoid — manual JSX repetition, useEffect misuse, reference instability, prop drilling, conditional hooks, and common performance traps
03-state-management
React state management patterns — lifting state, colocation, URL state, server state with TanStack Query, local state with useState/useReducer
02-component-composition
React component composition patterns — render props, compound components, list rendering with map, slot patterns, and layout primitives
01-fp-style
React functional programming style — pure components, hooks conventions, Effect Atom integration, and Tailwind patterns
react-animation
React animation with react/motion (Framer Motion) — declarative animations, gestures, layout animations, and performance best practices