AGENT SKILL

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";
  • it and describe — always from @effect/vitest
  • assertions — always from @effect/vitest/utils (Effect-aware, uses Equal.equals)
  • never import it/describe from "vitest"
  • never use expect from "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:

  1. Fix the underlying code issue (rename shadowed variables, remove unused imports, etc.)
  2. If the warning is a false positive, refactor the code to avoid triggering the rule
  3. 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