Vitest Patterns You’ll Use Daily

You already use Vitest in ai-skills-sync, mysukari.com, and starlight-action. This covers patterns beyond the basics.

Config Patterns

Shared config with Vite (mysukari.com pattern)

// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    environment: "jsdom",
    setupFiles: ["./test/setup.ts"],
    coverage: {
      provider: "v8",
      include: ["src/**/*.ts", "src/**/*.tsx"],
    },
  },
});

Vitest reuses Vite’s plugin system. Your @vitejs/plugin-react and vite-tsconfig-paths work in both dev and test.

Minimal config (ai-skills-sync pattern)

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    passWithNoTests: true,
  },
});

For CLI tools without DOM, you don’t even need an environment setting.

Testing Patterns

Mock external commands (CLI tools)

From ai-skills-sync: testing a CLI that calls git clone:

import { vi, describe, it, expect, beforeEach } from "vitest";
import { execa } from "execa";

vi.mock("execa", () => ({
  execa: vi.fn(),
}));

const mockExeca = vi.mocked(execa);

beforeEach(() => {
  mockExeca.mockReset();
});

it("clones the repo", async () => {
  mockExeca.mockResolvedValueOnce({ stdout: "" } as any);
  await fetchSkill("https://github.com/owner/repo");
  expect(mockExeca).toHaveBeenCalledWith(
    "git",
    expect.arrayContaining(["clone"]),
    expect.any(Object)
  );
});

React component testing (mysukari.com pattern)

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import { MyComponent } from "./MyComponent";

it("handles user interaction", async () => {
  const user = userEvent.setup();
  render(<MyComponent />);

  await user.click(screen.getByRole("button", { name: /submit/i }));
  expect(screen.getByText("Success")).toBeInTheDocument();
});

Snapshot testing

it("renders correctly", () => {
  const { container } = render(<Card title="Test" />);
  expect(container).toMatchSnapshot();
});

// Inline snapshots (auto-updated by vitest -u)
it("formats output", () => {
  expect(formatDate(new Date("2026-01-01"))).toMatchInlineSnapshot(
    `"January 1, 2026"`
  );
});

Testing async operations with timeouts

import { vi, it, expect } from "vitest";

it("retries on failure", async () => {
  vi.useFakeTimers();

  const promise = fetchWithRetry("/api/data");
  await vi.advanceTimersByTimeAsync(3000);

  await expect(promise).resolves.toEqual({ data: "ok" });
  vi.useRealTimers();
});

Coverage

V8 coverage (fast, native)

vitest run --coverage
// vitest.config.ts
test: {
  coverage: {
    provider: "v8",
    include: ["src/**/*.ts"],
    exclude: ["src/**/*.test.ts", "src/types.ts"],
    thresholds: {
      statements: 80,
      branches: 75,
    },
  },
}

Note: coverage include/exclude has a known bug in v4 with absolute paths (#9395). Use relative patterns.

Watch Mode Tricks

# Watch with UI dashboard
vitest --ui

# Filter by test name
vitest -t "should handle errors"

# Run only changed files
vitest --changed

# Run specific file
vitest src/utils.test.ts

Debugging Tests

# Run with Node inspector
vitest --inspect-brk --single-thread

# Dump transforms (see what Vite does to your code)
vitest --server.debug.dump

In VS Code, the Vitest extension gives you inline run/debug buttons.

Gotchas from Real Usage

  1. vi.mock is hoisted - it runs before imports, regardless of where you write it in the file. This is by design but can be confusing.

  2. jsdom vs happy-dom - jsdom is more complete, happy-dom is faster. For React testing with @testing-library, jsdom is safer.

  3. Worker isolation - each test file runs in its own worker by default. Global state doesn’t leak between files but does leak between tests in the same file.

  4. Coverage + vmForks - if you use --pool=vmForks (for component tests in mysukari.com), coverage may need different config.

Next Steps