Skip to content
AyoKoding

Intermediate

This tutorial covers intermediate Vitest techniques including module mocking, manual mocks, DOM testing, React component testing, coverage configuration, workspace setup, concurrent tests, type testing, and custom matchers used in production test suites.

Module Mocking (Examples 31-38)

Example 31: vi.mock - Mocking Entire Modules

vi.mock replaces an entire module with a mock implementation. Vitest hoists vi.mock calls to the top of the file, so they execute before any imports.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    A["vi.mock#40;'./module'#41;"] --> B["Hoisted to Top<br/>of File"]
    B --> C["Module Imports<br/>Receive Mock"]
    C --> D["All Exports<br/>Auto-Mocked"]
    D --> E["Functions return<br/>undefined by default"]
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style D fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000
    style E fill:#CA9161,stroke:#000000,stroke-width:2px,color:#fff

Code:

import { test, expect, vi } from "vitest";
 
// Mock the module BEFORE importing
// vi.mock is hoisted -- order in file doesn't matter
vi.mock("./userService", () => ({
  // => Replaces ./userService module with mock
  // => Factory function returns mock exports
  fetchUser: vi.fn().mockResolvedValue({
    id: 1,
    name: "Mock User",
  }),
  // => fetchUser returns resolved promise with mock data
  deleteUser: vi.fn().mockResolvedValue(true),
  // => deleteUser returns resolved promise with true
}));
 
// This import receives the mock, not the real module
// (For self-containment, we define the types inline)
const { fetchUser, deleteUser } = (await import("./userService")) as {
  fetchUser: ReturnType<typeof vi.fn>;
  deleteUser: ReturnType<typeof vi.fn>;
};
// => Imports receive mocked functions
// => Real module never loaded
 
test("uses mocked module", async () => {
  const user = await fetchUser(1);
  // => Calls mocked fetchUser
  // => Returns { id: 1, name: "Mock User" }
 
  expect(user).toEqual({ id: 1, name: "Mock User" });
  // => Passes: mock data returned
  expect(fetchUser).toHaveBeenCalledWith(1);
  // => Passes: call tracked with argument 1
});
 
test("verifies delete was called", async () => {
  await deleteUser(1);
  // => Calls mocked deleteUser
 
  expect(deleteUser).toHaveBeenCalledWith(1);
  // => Passes: deleteUser called with ID 1
  expect(deleteUser).toHaveBeenCalledTimes(1);
  // => Passes: called exactly once
});

Key Takeaway: vi.mock is hoisted to the top of the file automatically, so import order does not matter. The factory function replaces all module exports with mocks.

Why It Matters: Module mocking isolates the unit under test from its dependencies. When testing a controller that calls a service, mocking the service module means you test the controller's logic without hitting databases or APIs. Vitest's automatic hoisting simplifies the common pattern where mocks must be declared before imports -- unlike Jest, you don't need to manually manage mock placement or use jest.mock at the top of the file.


Example 32: vi.mock with Auto-Mocking

When vi.mock is called without a factory function, Vitest auto-mocks all exports. Functions become vi.fn(), objects become deep mocks, and classes have mocked constructors.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    A["vi.mock#40;'./module'#41;<br/>No Factory"] --> B["Auto-Mock Engine"]
    B --> C["Functions -> vi.fn#40;#41;"]
    B --> D["Objects -> Deep Mock"]
    B --> E["Classes -> Mock Constructor"]
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style D fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000
    style E fill:#CA9161,stroke:#000000,stroke-width:2px,color:#fff

Code:

import { test, expect, vi } from "vitest";
 
// Auto-mock: no factory function
vi.mock("./mathUtils");
// => All exports of ./mathUtils become vi.fn()
// => Functions return undefined by default
// => Classes have mocked constructors
 
// Self-contained demonstration of auto-mock behavior
const mockModule = {
  add: vi.fn(),
  subtract: vi.fn(),
  multiply: vi.fn(),
};
// => Simulates what auto-mock produces
 
test("auto-mocked functions return undefined", () => {
  const result = mockModule.add(2, 3);
  // => result is undefined (auto-mocked, no implementation)
 
  expect(result).toBeUndefined();
  // => Passes: auto-mocked function returns undefined
  expect(mockModule.add).toHaveBeenCalledWith(2, 3);
  // => Passes: call tracked despite no implementation
});
 
test("configure auto-mocked function behavior", () => {
  mockModule.add.mockReturnValue(10);
  // => Override auto-mock with specific return value
 
  expect(mockModule.add(2, 3)).toBe(10);
  // => Passes: returns configured value (not real 5)
  expect(mockModule.add).toHaveBeenCalledWith(2, 3);
  // => Passes: call tracked
 
  mockModule.subtract.mockImplementation((a: number, b: number) => a - b);
  // => Override with real implementation
  expect(mockModule.subtract(5, 3)).toBe(2);
  // => Passes: custom implementation runs
});
 
test("reset mock state between tests", () => {
  mockModule.add.mockClear();
  // => Clears call history (calls, instances, results)
  // => Does NOT remove mockReturnValue
 
  expect(mockModule.add).not.toHaveBeenCalled();
  // => Passes: call history cleared
 
  mockModule.add.mockReset();
  // => Clears call history AND removes mock implementations
  // => Function returns undefined again
 
  expect(mockModule.add(1, 2)).toBeUndefined();
  // => Passes: implementation removed by mockReset
});

Key Takeaway: Auto-mocking creates vi.fn() for all exports. Use mockClear() to reset call history and mockReset() to also remove implementations. Choose between clear and reset based on whether you want to keep configured behavior.

Why It Matters: Auto-mocking reduces boilerplate when you need to mock modules with many exports. Instead of writing a factory function for each export, auto-mock creates blanks that you configure per test. The mockClear vs mockReset distinction is critical -- using mockReset in afterEach ensures complete isolation, while mockClear preserves configured return values across tests. Getting this wrong causes subtle test interdependencies.


Example 33: Manual Mocks with mocks Directory

Vitest supports manual mock files in __mocks__ directories that automatically replace real modules when vi.mock is called.

import { test, expect, vi } from "vitest";
 
// File structure for manual mocks:
// src/
//   __mocks__/
//     axios.ts           => Manual mock for 'axios' package
//   services/
//     __mocks__/
//       userService.ts   => Manual mock for ./userService
//     userService.ts     => Real implementation
 
// When you call vi.mock('./userService'),
// Vitest checks for __mocks__/userService.ts first
 
// Self-contained manual mock demonstration
const manualMockAxios = {
  get: vi.fn().mockResolvedValue({ data: { id: 1, name: "Mock" } }),
  // => Manual mock for axios.get
  // => Defined once, reused across all tests
  post: vi.fn().mockResolvedValue({ data: { success: true } }),
  // => Manual mock for axios.post
};
 
test("uses manual mock", async () => {
  const response = await manualMockAxios.get("/api/users/1");
  // => Calls mocked axios.get
  // => response is { data: { id: 1, name: "Mock" } }
 
  expect(response.data).toEqual({ id: 1, name: "Mock" });
  // => Passes: manual mock returns predefined data
  expect(manualMockAxios.get).toHaveBeenCalledWith("/api/users/1");
  // => Passes: URL tracked
});
 
test("manual mock for POST", async () => {
  const response = await manualMockAxios.post("/api/users", {
    name: "New User",
  });
  // => Calls mocked axios.post with body
 
  expect(response.data.success).toBe(true);
  // => Passes: POST mock returns success
  expect(manualMockAxios.post).toHaveBeenCalledWith("/api/users", {
    name: "New User",
  });
  // => Passes: URL and body tracked
});

Key Takeaway: Place manual mock files in __mocks__ directories adjacent to the real module. Vitest uses them automatically when vi.mock is called. Centralized mocks reduce duplication across test files.

Why It Matters: Large projects mock the same modules (axios, fs, database clients) across dozens of test files. Without manual mocks, each test file duplicates the mock factory. Manual mocks define the mock once and share it across the entire test suite. When the real module's API changes, you update one mock file instead of searching through every test file. This is the standard pattern for mocking HTTP clients, file systems, and other infrastructure dependencies.


Example 34: Mocking Module Factories with Partial Mocking

Sometimes you need to mock some exports while keeping others real. vi.importActual retrieves the real module for partial mocking.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
    A["vi.importActual#40;#41;"] --> B["Real Module Exports"]
    B --> C["Spread ...actual"]
    D["vi.fn#40;#41;"] --> E["Mock Override"]
    C --> F["Partial Mock<br/>Real + Mock"]
    E --> F
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style D fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style E fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style F fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000

Code:

import { test, expect, vi } from "vitest";
 
// Simulating partial mock pattern
// In real code: vi.mock('./utils', async () => {
//   const actual = await vi.importActual('./utils');
//   return { ...actual, riskyFunction: vi.fn() };
// });
 
// Self-contained demonstration
const realModule = {
  safeFunction: (x: number) => x * 2,
  // => Real implementation we want to keep
  riskyFunction: (url: string) => fetch(url),
  // => Real implementation we want to mock
  CONSTANT: 42,
  // => Constant we want to keep
};
 
// Partial mock: keep safe functions, mock risky ones
const partialMock = {
  ...realModule,
  // => Spread all real exports
  riskyFunction: vi.fn().mockResolvedValue("mocked response"),
  // => Override only riskyFunction with mock
};
 
test("partial mock keeps real implementations", () => {
  expect(partialMock.safeFunction(5)).toBe(10);
  // => Passes: real implementation (5 * 2 = 10)
  expect(partialMock.CONSTANT).toBe(42);
  // => Passes: real constant preserved
});
 
test("partial mock replaces specific functions", async () => {
  const result = await partialMock.riskyFunction("https://api.example.com");
  // => Calls mocked riskyFunction
  // => result is "mocked response"
 
  expect(result).toBe("mocked response");
  // => Passes: mock returned instead of real fetch
  expect(partialMock.riskyFunction).toHaveBeenCalledWith("https://api.example.com");
  // => Passes: call tracked with URL
});

Key Takeaway: Use vi.importActual inside vi.mock factory to get real exports, then spread and override specific functions. This keeps utility functions real while mocking I/O operations.

Why It Matters: Over-mocking leads to tests that verify mock behavior rather than real code. If a module exports 10 functions and you only need to mock the one that hits the network, partial mocking keeps the other 9 executing real logic. This catches bugs in utility functions that full mocking would hide. The pattern is essential for testing modules that mix pure logic (safe to run) with side effects (needs mocking).


Example 35: Mocking Classes

Vitest can mock class constructors and methods for testing code that depends on class instances.

import { test, expect, vi } from "vitest";
 
// Class to be mocked
class EmailService {
  constructor(private apiKey: string) {
    // => Constructor stores API key
  }
 
  async send(to: string, subject: string, body: string): Promise<boolean> {
    // => Sends email via external API
    console.log(`Sending to ${to}: ${subject}`);
    return true;
    // => Returns true on success
  }
 
  validate(email: string): boolean {
    // => Validates email format
    return email.includes("@");
    // => Simple validation check
  }
}
 
// Mock the class
const MockEmailService = vi.fn().mockImplementation((apiKey: string) => ({
  // => vi.fn() creates mock constructor
  // => mockImplementation defines what "new" returns
  send: vi.fn().mockResolvedValue(true),
  // => Mock send method
  validate: vi.fn().mockReturnValue(true),
  // => Mock validate method
  apiKey,
  // => Store apiKey for verification
}));
 
test("mocked class constructor", () => {
  const service = new (MockEmailService as unknown as typeof EmailService)("test-key");
  // => Creates instance from mock constructor
  // => No real EmailService instantiated
 
  expect(MockEmailService).toHaveBeenCalledWith("test-key");
  // => Passes: constructor called with API key
});
 
test("mocked class methods", async () => {
  const service = new (MockEmailService as unknown as typeof EmailService)("test-key");
 
  const sent = await service.send("user@test.com", "Hello", "Body");
  // => Calls mocked send method
  // => sent is true (mock return)
 
  expect(sent).toBe(true);
  // => Passes: mock returned true
  expect(service.send).toHaveBeenCalledWith("user@test.com", "Hello", "Body");
  // => Passes: arguments tracked
});

Key Takeaway: Mock classes by creating a mock constructor with vi.fn().mockImplementation() that returns an object with mocked methods. Track constructor calls and method invocations separately.

Why It Matters: Service classes (email, payment, notification) are common in enterprise applications. Testing code that instantiates these services requires mocking the constructor to prevent real API calls and verify correct configuration. This pattern verifies that your code passes the right API key, calls the right methods, and handles responses correctly -- all without sending real emails or charging real credit cards.


Example 36: Mock Implementations for Complex Scenarios

mockImplementation provides full control over mock behavior, enabling stateful mocks and conditional responses.

import { test, expect, vi } from "vitest";
 
test("stateful mock implementation", () => {
  let callCount = 0;
  // => Track state across calls
 
  const mockFn = vi.fn().mockImplementation(() => {
    // => Custom implementation with state
    callCount++;
    if (callCount === 1) return "first call";
    // => First call returns specific value
    if (callCount === 2) return "second call";
    // => Second call returns different value
    return "subsequent call";
    // => All other calls return default
  });
 
  expect(mockFn()).toBe("first call");
  // => Passes: first invocation
  expect(mockFn()).toBe("second call");
  // => Passes: second invocation
  expect(mockFn()).toBe("subsequent call");
  // => Passes: third invocation
  expect(mockFn()).toBe("subsequent call");
  // => Passes: fourth invocation (same as third)
});
 
test("conditional mock based on arguments", () => {
  const mockFetch = vi.fn().mockImplementation(async (url: string) => {
    // => Route-based mock responses
    if (url === "/api/users") {
      return { status: 200, data: [{ id: 1 }] };
      // => Users endpoint returns array
    }
    if (url === "/api/health") {
      return { status: 200, data: { healthy: true } };
      // => Health check returns status
    }
    return { status: 404, data: null };
    // => Unknown routes return 404
  });
 
  // Test different routes
  expect(await mockFetch("/api/users")).toEqual({
    status: 200,
    data: [{ id: 1 }],
  });
  // => Passes: users endpoint returns user array
 
  expect(await mockFetch("/api/health")).toEqual({
    status: 200,
    data: { healthy: true },
  });
  // => Passes: health endpoint returns status
 
  expect(await mockFetch("/api/unknown")).toEqual({
    status: 404,
    data: null,
  });
  // => Passes: unknown route returns 404
});

Key Takeaway: Use mockImplementation for mocks that need state, conditional logic, or argument-dependent behavior. This provides the flexibility of real code with the tracking benefits of mocks.

Why It Matters: Production APIs have different behaviors based on inputs -- paginated responses, rate limiting, conditional errors. Simple mockReturnValue cannot simulate these patterns. mockImplementation enables testing complex interaction flows: "first request returns page 1, second returns page 2, third returns empty" or "requests after the 5th return 429 rate limit." These scenarios are impossible to test without implementation-based mocks.


Example 37: Mocking Global Functions - fetch

Mocking global functions like fetch enables testing HTTP-dependent code without network access.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    A["Code calls fetch#40;#41;"] --> B["global.fetch = vi.fn#40;#41;"]
    B --> C{"Mock Response"}
    C -->|ok: true| D["Success Path"]
    C -->|ok: false| E["Error Path"]
    C -->|reject| F["Network Error"]
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000
    style D fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style E fill:#CA9161,stroke:#000000,stroke-width:2px,color:#fff
    style F fill:#CA9161,stroke:#000000,stroke-width:2px,color:#fff

Code:

import { test, expect, vi, beforeEach, afterEach } from "vitest";
 
// Function that uses global fetch
async function getUser(id: number): Promise<{ id: number; name: string }> {
  const response = await fetch(`/api/users/${id}`);
  // => Calls global fetch
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
    // => Throws on non-2xx responses
  }
  return response.json();
  // => Parses JSON response
}
 
beforeEach(() => {
  // Mock global fetch
  global.fetch = vi.fn();
  // => Replace global fetch with mock
});
 
afterEach(() => {
  vi.restoreAllMocks();
  // => Restore all mocked globals
});
 
test("mocks successful fetch", async () => {
  const mockResponse = {
    ok: true,
    status: 200,
    json: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
    // => Mock json() method returns parsed data
  };
  (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
  // => fetch resolves with mock Response object
 
  const user = await getUser(1);
  // => Calls mocked fetch, gets mock response
 
  expect(user).toEqual({ id: 1, name: "Alice" });
  // => Passes: parsed mock JSON data
  expect(global.fetch).toHaveBeenCalledWith("/api/users/1");
  // => Passes: correct URL called
});
 
test("mocks failed fetch", async () => {
  const mockResponse = {
    ok: false,
    status: 404,
    json: vi.fn(),
  };
  (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);
  // => fetch resolves with 404 Response
 
  await expect(getUser(999)).rejects.toThrow("HTTP 404");
  // => Passes: function throws for non-2xx status
});
 
test("mocks network error", async () => {
  (global.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
  // => fetch rejects (simulates network failure)
 
  await expect(getUser(1)).rejects.toThrow("Network error");
  // => Passes: network error propagated
});

Key Takeaway: Mock global.fetch to test HTTP-dependent code without network access. Create mock Response objects with ok, status, and json() properties to simulate different HTTP scenarios.

Why It Matters: HTTP calls are the most common external dependency in web applications. Testing without mocking fetch means tests depend on network availability, API uptime, and data consistency -- making them slow and flaky. Mocking fetch lets you test all HTTP scenarios deterministically: success, various error codes, timeouts, and malformed responses. This is the foundation of reliable API client testing.


Example 38: vi.mocked - Type-Safe Mock Access

vi.mocked provides type-safe access to mocked functions, enabling auto-completion and type checking on mock methods.

import { test, expect, vi } from "vitest";
 
// Original function type
function fetchData(url: string): Promise<{ data: string }> {
  // => Real function signature
  return fetch(url).then((r) => r.json());
}
 
// Create a typed mock
const mockedFetch = vi.fn<typeof fetchData>();
// => Creates vi.fn typed as fetchData
// => Type-safe: mockResolvedValue must match return type
 
test("type-safe mock configuration", async () => {
  mockedFetch.mockResolvedValue({ data: "mocked" });
  // => TypeScript knows return type is Promise<{ data: string }>
  // => mockedFetch.mockResolvedValue({ wrong: true }) would be a type error
 
  const result = await mockedFetch("/api/data");
  // => result type is { data: string } (inferred from mock type)
 
  expect(result.data).toBe("mocked");
  // => Passes: type-safe access to .data property
  expect(mockedFetch).toHaveBeenCalledWith("/api/data");
  // => Passes: call tracked with argument
});
 
test("vi.mocked helper for imported mocks", () => {
  // When working with vi.mock'd modules:
  // const { fetchData } = await import('./api');
  // const mocked = vi.mocked(fetchData);
  // => vi.mocked() wraps function with mock types
  // => Enables .mockReturnValue, .mockImplementation with type safety
 
  // Self-contained demonstration
  const original = (x: number): string => String(x);
  // => Original function
  const mockOriginal = vi.fn(original);
  // => Mock wrapping original
 
  const typed = vi.mocked(mockOriginal);
  // => typed has full mock methods with type safety
  typed.mockReturnValue("42");
  // => TypeScript knows return must be string
 
  expect(typed(1)).toBe("42");
  // => Passes: mock return value used
});

Key Takeaway: Use vi.fn<typeof realFunction>() for typed mock creation and vi.mocked() for type-safe access to existing mocks. TypeScript catches type mismatches in mock configurations at compile time.

Why It Matters: Type-unsafe mocks are a hidden source of test bugs. A mock configured to return { data: "test" } when the real function returns { results: "test" } creates a test that passes against mock data but misses the real shape mismatch. Typed mocks catch these discrepancies at compile time, ensuring your mocks accurately represent the contracts they replace. This is especially critical in TypeScript codebases where type safety is a core value.


DOM and Component Testing (Examples 39-45)

Example 39: DOM Testing with happy-dom

Vitest supports DOM environments through happy-dom or jsdom. Configure the environment per file or globally for testing browser code.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    A["vitest.config.ts<br/>environment: happy-dom"] --> B["DOM APIs Available<br/>document, window"]
    B --> C["Test DOM<br/>Manipulation"]
    B --> D["Test Event<br/>Handlers"]
    B --> E["Test Browser<br/>APIs"]
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style D fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000
    style E fill:#CA9161,stroke:#000000,stroke-width:2px,color:#fff

Code:

// @vitest-environment happy-dom
// => Per-file environment directive
// => Enables DOM APIs for this file only
 
import { test, expect } from "vitest";
 
test("creates and queries DOM elements", () => {
  const div = document.createElement("div");
  // => Creates <div> element in happy-dom
  div.id = "test-element";
  // => Sets id attribute
  div.textContent = "Hello, Vitest!";
  // => Sets text content
 
  document.body.appendChild(div);
  // => Appends to document body
 
  const found = document.getElementById("test-element");
  // => Queries by ID
  // => found is the <div> element
 
  expect(found).not.toBeNull();
  // => Passes: element exists in DOM
  expect(found?.textContent).toBe("Hello, Vitest!");
  // => Passes: text content matches
 
  // Cleanup
  document.body.removeChild(div);
  // => Remove element after test
});
 
test("handles DOM events", () => {
  const button = document.createElement("button");
  // => Creates <button> element
  let clicked = false;
  // => Track click state
 
  button.addEventListener("click", () => {
    clicked = true;
    // => Event handler sets flag
  });
 
  button.click();
  // => Programmatically triggers click event
  // => Event handler executes synchronously
 
  expect(clicked).toBe(true);
  // => Passes: click handler was invoked
});
 
test("manipulates CSS classes", () => {
  const element = document.createElement("span");
  // => Creates <span> element
 
  element.classList.add("active", "visible");
  // => Adds two CSS classes
 
  expect(element.classList.contains("active")).toBe(true);
  // => Passes: element has "active" class
  expect(element.className).toBe("active visible");
  // => Passes: className contains both classes
 
  element.classList.remove("active");
  // => Removes "active" class
  expect(element.classList.contains("active")).toBe(false);
  // => Passes: "active" class removed
});

Key Takeaway: Use // @vitest-environment happy-dom for per-file DOM environment or configure globally in vitest.config.ts. happy-dom is faster than jsdom for most use cases.

Why It Matters: Frontend code manipulates the DOM, handles events, and uses browser APIs. Without a DOM environment, these tests require a real browser. happy-dom provides a lightweight JavaScript implementation of DOM APIs that runs in Node.js, enabling fast unit tests for browser code. happy-dom is 2-3x faster than jsdom for most operations, making it the preferred choice for test suites with hundreds of DOM-dependent tests.


Example 40: Testing with jsdom Environment

jsdom provides more complete browser API coverage than happy-dom, useful when testing code that relies on advanced browser features.

// @vitest-environment jsdom
// => Uses jsdom instead of happy-dom
// => More complete API coverage, slightly slower
 
import { test, expect } from "vitest";
 
test("jsdom provides window properties", () => {
  expect(window).toBeDefined();
  // => Passes: window object available
  expect(document).toBeDefined();
  // => Passes: document object available
  expect(navigator).toBeDefined();
  // => Passes: navigator object available
});
 
test("localStorage in jsdom", () => {
  localStorage.setItem("key", "value");
  // => Sets item in localStorage (jsdom implementation)
 
  expect(localStorage.getItem("key")).toBe("value");
  // => Passes: retrieves stored value
 
  localStorage.removeItem("key");
  // => Cleans up storage
  expect(localStorage.getItem("key")).toBeNull();
  // => Passes: item removed
});
 
test("form element behavior", () => {
  const form = document.createElement("form");
  // => Creates form element
  const input = document.createElement("input");
  // => Creates input element
  input.type = "text";
  // => Sets input type
  input.name = "username";
  // => Sets input name
  input.value = "alice";
  // => Sets input value
 
  form.appendChild(input);
  // => Adds input to form
 
  const formData = new FormData(form);
  // => Creates FormData from form
  // => jsdom supports FormData API
 
  expect(formData.get("username")).toBe("alice");
  // => Passes: FormData extracted value from input
});

Key Takeaway: Use jsdom when testing code that requires advanced browser APIs like FormData, localStorage, or complex event handling. Use happy-dom for simpler DOM testing where speed matters more.

Why It Matters: The choice between happy-dom and jsdom depends on API coverage needs. happy-dom covers ~95% of DOM APIs at higher speed, while jsdom covers ~99% with more faithful browser simulation. Tests using Web Workers, Intersection Observer, or complex form APIs need jsdom. The per-file environment directive lets you mix environments in one project -- fast happy-dom for most tests, jsdom only where needed.


Example 41: Testing React Components with Vitest

Vitest integrates with Testing Library for React component testing. The DOM environment provides the foundation for rendering and interacting with components.

// @vitest-environment happy-dom
import { test, expect, vi } from "vitest";
 
// Self-contained React component simulation
// (In real code, use @testing-library/react)
// Demonstrating the pattern without requiring React dependency
 
interface Component {
  render: () => string;
  handleClick: () => void;
  getState: () => { count: number };
}
 
function createCounter(initial: number = 0): Component {
  // => Factory function simulating React component behavior
  let count = initial;
  // => Internal state (like useState)
 
  return {
    render: () => `Count: ${count}`,
    // => Render output (like JSX)
    handleClick: () => {
      count++;
      // => State update (like setState)
    },
    getState: () => ({ count }),
    // => State access for assertions
  };
}
 
test("renders component with initial state", () => {
  const counter = createCounter(0);
  // => Create component with initial count 0
 
  expect(counter.render()).toBe("Count: 0");
  // => Passes: renders initial state
  expect(counter.getState().count).toBe(0);
  // => Passes: state is 0
});
 
test("handles click events", () => {
  const counter = createCounter(0);
  // => Create fresh component
 
  counter.handleClick();
  // => Simulate click event
  counter.handleClick();
  // => Simulate another click
 
  expect(counter.getState().count).toBe(2);
  // => Passes: state updated twice
  expect(counter.render()).toBe("Count: 2");
  // => Passes: render reflects new state
});
 
test("initializes with custom value", () => {
  const counter = createCounter(10);
  // => Create component with initial count 10
 
  expect(counter.render()).toBe("Count: 10");
  // => Passes: respects initial value
});

Key Takeaway: Vitest provides the DOM environment; combine with Testing Library for React/Vue/Svelte component testing. Test components through their public API (renders, events, state) rather than implementation details.

Why It Matters: Component testing bridges the gap between unit tests and E2E tests. Unit tests verify logic, E2E tests verify user workflows, and component tests verify that UI elements render correctly and respond to interactions. Vitest's Vite-native transforms handle JSX, CSS modules, and asset imports identically to your build, ensuring component tests accurately reflect production behavior without the overhead of browser-based E2E testing.


Example 42: Testing Custom Hooks Pattern

Custom hooks encapsulate reusable state logic. Testing them requires rendering within a component context.

// @vitest-environment happy-dom
import { test, expect } from "vitest";
 
// Self-contained hook simulation
// (In real code, use @testing-library/react renderHook)
 
function createUseToggle(initial: boolean = false) {
  // => Simulates a useToggle custom hook
  let state = initial;
  // => Internal boolean state
 
  const toggle = () => {
    state = !state;
    // => Flips boolean value
  };
 
  const setValue = (value: boolean) => {
    state = value;
    // => Sets explicit value
  };
 
  return {
    getState: () => state,
    // => Current state accessor
    toggle,
    // => Toggle function
    setValue,
    // => Set function
  };
}
 
test("toggle hook starts with initial value", () => {
  const hook = createUseToggle(false);
  // => Create hook with false initial
 
  expect(hook.getState()).toBe(false);
  // => Passes: initial state is false
});
 
test("toggle hook flips state", () => {
  const hook = createUseToggle(false);
 
  hook.toggle();
  // => First toggle: false -> true
  expect(hook.getState()).toBe(true);
  // => Passes: state is now true
 
  hook.toggle();
  // => Second toggle: true -> false
  expect(hook.getState()).toBe(false);
  // => Passes: state flipped back
});
 
test("toggle hook sets explicit value", () => {
  const hook = createUseToggle(false);
 
  hook.setValue(true);
  // => Explicitly set to true
  expect(hook.getState()).toBe(true);
  // => Passes: state set to true
 
  hook.setValue(true);
  // => Set to true again (idempotent)
  expect(hook.getState()).toBe(true);
  // => Passes: still true (not toggled)
});

Key Takeaway: Test hooks through their return values and side effects, not their internal implementation. Verify initial state, state transitions, and edge cases (idempotent operations, boundary values).

Why It Matters: Custom hooks contain the majority of React application logic -- data fetching, form state, authentication, real-time connections. Testing hooks in isolation catches bugs earlier than component-level testing and runs faster. The pattern of testing through the public API (returned values and functions) ensures hooks can be refactored internally without breaking tests, supporting the "make it work, make it right, make it fast" development workflow.


Example 43: Testing Async Components

Testing components that perform async operations (data fetching, animations, transitions) requires handling promises and state updates.

// @vitest-environment happy-dom
import { test, expect, vi } from "vitest";
 
// Simulates async component (data fetching pattern)
function createAsyncList(fetchFn: () => Promise<string[]>) {
  // => Component that fetches and displays a list
  let items: string[] = [];
  let loading = true;
  let error: string | null = null;
 
  const load = async () => {
    // => Triggers data fetch
    loading = true;
    error = null;
    try {
      items = await fetchFn();
      // => Awaits data from fetch function
    } catch (e) {
      error = (e as Error).message;
      // => Captures error message
    } finally {
      loading = false;
      // => Loading complete regardless of outcome
    }
  };
 
  return {
    load,
    getState: () => ({ items, loading, error }),
    // => State accessor for assertions
  };
}
 
test("async component loading state", async () => {
  const mockFetch = vi.fn().mockResolvedValue(["item-1", "item-2"]);
  // => Mock fetch returns two items
 
  const list = createAsyncList(mockFetch);
  // => Create component with mock fetch
 
  expect(list.getState().loading).toBe(true);
  // => Passes: initial loading state
 
  await list.load();
  // => Trigger and await data fetch
 
  expect(list.getState().loading).toBe(false);
  // => Passes: loading complete
  expect(list.getState().items).toEqual(["item-1", "item-2"]);
  // => Passes: items loaded
  expect(list.getState().error).toBeNull();
  // => Passes: no error
});
 
test("async component error state", async () => {
  const mockFetch = vi.fn().mockRejectedValue(new Error("Network error"));
  // => Mock fetch rejects
 
  const list = createAsyncList(mockFetch);
  await list.load();
  // => Trigger fetch (which rejects)
 
  expect(list.getState().loading).toBe(false);
  // => Passes: loading complete (even on error)
  expect(list.getState().error).toBe("Network error");
  // => Passes: error message captured
  expect(list.getState().items).toEqual([]);
  // => Passes: items empty on error
});

Key Takeaway: Test async components through their loading, success, and error states. Mock the data source to control which state the component enters, and await the async operation before asserting.

Why It Matters: Every production component that fetches data has three states: loading, success, and error. Skipping error state testing means users see uncaught exceptions instead of error messages. Skipping loading state testing means users see blank screens. Testing all three states through controlled mocks ensures your components handle the full lifecycle of async operations, which is critical for user experience in network-dependent applications.


Example 44: Inline Test Environment with @vitest-environment

Vitest supports per-file environment overrides using a comment directive, enabling mixed environments in one project.

// @vitest-environment happy-dom
// => This file runs in happy-dom environment
// => Overrides the global environment from vitest.config.ts
// => Only affects this specific test file
 
import { test, expect } from "vitest";
 
test("DOM available with per-file directive", () => {
  expect(typeof document).toBe("object");
  // => Passes: document available in happy-dom
 
  expect(typeof window).toBe("object");
  // => Passes: window available in happy-dom
 
  const el = document.createElement("p");
  // => Creates paragraph element
  el.textContent = "Created in happy-dom";
  // => Sets content
 
  expect(el.textContent).toBe("Created in happy-dom");
  // => Passes: DOM manipulation works
});
 
// In another file without the directive:
// (assumes global environment is "node")
//
// test("no DOM in node environment", () => {
//   expect(typeof document).toBe("undefined");
//   // => Passes in node environment: no DOM
// });
 
// Environment options: node, happy-dom, jsdom, edge-runtime
// => node: default, no DOM (fastest)
// => happy-dom: lightweight DOM (fast)
// => jsdom: full DOM simulation (complete)
// => edge-runtime: Cloudflare Workers/Vercel Edge
 
test("environment applies to entire file", () => {
  // All tests in this file share happy-dom environment
  expect(navigator.userAgent).toContain("happy-dom");
  // => Passes: happy-dom provides navigator
  // => Each environment has its own user agent string
});

Key Takeaway: Use // @vitest-environment <name> comment at the top of test files to override the global environment. This enables testing DOM code and Node.js code in the same project without separate configurations.

Why It Matters: Full-stack projects have both server code (Node.js) and client code (browser). Running all tests in jsdom wastes performance on server tests that don't need DOM. Running all in node breaks DOM tests. Per-file environment directives let you assign the optimal environment to each test file, maximizing both speed and correctness. This is Vitest's approach to the problem that forces Jest users to maintain separate configurations for server and client tests.


Example 45: Testing with Cleanup Patterns

DOM tests require cleanup to prevent element leaks between tests. Proper cleanup ensures test isolation in shared DOM environments.

// @vitest-environment happy-dom
import { test, expect, afterEach } from "vitest";
 
afterEach(() => {
  // Clean up DOM after each test
  document.body.innerHTML = "";
  // => Removes all child elements from body
  // => Prevents DOM leaks between tests
});
 
test("first test adds elements", () => {
  const div = document.createElement("div");
  div.id = "test-1";
  // => Creates element with unique ID
  document.body.appendChild(div);
 
  expect(document.getElementById("test-1")).not.toBeNull();
  // => Passes: element exists
  expect(document.body.children).toHaveLength(1);
  // => Passes: one child element
});
 
test("second test starts with clean DOM", () => {
  // afterEach cleared the DOM
  expect(document.body.children).toHaveLength(0);
  // => Passes: no leftover elements from previous test
 
  expect(document.getElementById("test-1")).toBeNull();
  // => Passes: previous test's element was cleaned up
 
  const span = document.createElement("span");
  span.id = "test-2";
  document.body.appendChild(span);
 
  expect(document.getElementById("test-2")).not.toBeNull();
  // => Passes: this test's element exists
});
 
test("third test also starts clean", () => {
  expect(document.body.children).toHaveLength(0);
  // => Passes: cleanup ran after second test
  // => Each test starts with empty DOM
});

Key Takeaway: Clear document.body.innerHTML in afterEach to prevent DOM leaks. Testing Library's cleanup() does this automatically; when testing without it, manual cleanup is essential.

Why It Matters: DOM leaks are the most common cause of flaky UI tests. Element A from test 1 affects queries in test 2 -- getByText("Submit") finds the wrong button, or querySelectorAll("button") returns unexpected counts. The cleanup pattern ensures each test starts with a blank slate. In real projects, Testing Library's automatic cleanup handles this, but understanding the underlying principle prevents debugging hours when cleanup doesn't work as expected.


Coverage and Configuration (Examples 46-50)

Example 46: Coverage Configuration

Vitest supports code coverage through v8 (default, fast) or istanbul (traditional, detailed) providers. Configuration determines which files are measured and what thresholds apply.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    A["vitest.config.ts<br/>coverage config"] --> B{"Provider?"}
    B -->|v8| C["V8 Built-in Coverage<br/>#40;Fast, native#41;"]
    B -->|istanbul| D["Istanbul Instrumentation<br/>#40;Detailed, traditional#41;"]
    C --> E["Reports:<br/>text, lcov, html"]
    D --> E
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style D fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000
    style E fill:#CA9161,stroke:#000000,stroke-width:2px,color:#fff

Code:

// vitest.config.ts - Coverage configuration
import { defineConfig } from "vitest/config";
 
export default defineConfig({
  test: {
    coverage: {
      // => Coverage configuration section
      provider: "v8",
      // => "v8": uses V8's built-in coverage (fast, default)
      // => "istanbul": uses Istanbul instrumentation (detailed)
 
      reporter: ["text", "lcov", "html"],
      // => "text": terminal output with summary table
      // => "lcov": machine-readable for CI tools
      // => "html": browsable HTML report
 
      include: ["src/**/*.ts"],
      // => Only measure coverage for source files
      // => Excludes test files, configs, scripts
      exclude: ["src/**/*.test.ts", "src/**/*.d.ts"],
      // => Explicitly exclude test and type declaration files
 
      thresholds: {
        // => Fail if coverage drops below thresholds
        lines: 80,
        // => 80% line coverage minimum
        functions: 80,
        // => 80% function coverage minimum
        branches: 80,
        // => 80% branch coverage minimum
        statements: 80,
        // => 80% statement coverage minimum
      },
    },
  },
});
 
// Self-contained test to demonstrate coverage concepts
import { test, expect } from "vitest";
 
function calculateDiscount(price: number, tier: string): number {
  // => Function with multiple branches (coverage target)
  if (tier === "gold") return price * 0.8;
  // => Gold tier: 20% discount
  if (tier === "silver") return price * 0.9;
  // => Silver tier: 10% discount
  return price;
  // => No discount for other tiers
}
 
test("covers gold tier", () => {
  expect(calculateDiscount(100, "gold")).toBe(80);
  // => Tests gold branch (20% discount)
});
 
test("covers silver tier", () => {
  expect(calculateDiscount(100, "silver")).toBe(90);
  // => Tests silver branch (10% discount)
});
 
test("covers default tier", () => {
  expect(calculateDiscount(100, "bronze")).toBe(100);
  // => Tests default branch (no discount)
  // => All three branches covered: 100% branch coverage
});

Key Takeaway: Configure coverage in vitest.config.ts with provider, reporters, included files, and thresholds. Run npx vitest run --coverage to generate reports.

Why It Matters: Coverage thresholds prevent test quality regression. Without thresholds, teams gradually stop writing tests as deadlines approach. With 80% enforcement, CI blocks merges when coverage drops, maintaining quality automatically. The v8 provider adds minimal overhead (1-2% slower), while istanbul provides source-mapped coverage that's easier to debug. LCOV output integrates with CI tools (Codecov, SonarQube) for trend tracking across releases.


Example 47: In-Source Testing

Vitest supports defining tests directly in source files, co-locating tests with the code they verify. Tests are tree-shaken from production builds.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    A["source.ts<br/>Code + Tests"] --> B{"import.meta.vitest?"}
    B -->|Vitest| C["Tests Execute"]
    B -->|Production Build| D["Tree-Shaken<br/>#40;Tests Removed#41;"]
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style D fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000

Code:

// src/utils/math.ts (source file with in-source tests)
 
export function fibonacci(n: number): number {
  // => Calculates nth Fibonacci number
  if (n <= 0) return 0;
  // => Base case: fib(0) = 0
  if (n === 1) return 1;
  // => Base case: fib(1) = 1
  return fibonacci(n - 1) + fibonacci(n - 2);
  // => Recursive case: sum of previous two
}
 
export function isPrime(n: number): boolean {
  // => Checks if number is prime
  if (n < 2) return false;
  // => Numbers less than 2 are not prime
  for (let i = 2; i <= Math.sqrt(n); i++) {
    if (n % i === 0) return false;
    // => Divisible by i, not prime
  }
  return true;
  // => No divisors found, number is prime
}
 
// In-source tests (removed from production build)
if (import.meta.vitest) {
  // => Only executes in Vitest environment
  // => Tree-shaken from production builds
  // => import.meta.vitest is undefined in production
 
  const { test, expect } = import.meta.vitest;
  // => Access test and expect from Vitest
 
  test("fibonacci base cases", () => {
    expect(fibonacci(0)).toBe(0);
    // => fib(0) = 0
    expect(fibonacci(1)).toBe(1);
    // => fib(1) = 1
  });
 
  test("fibonacci recursive cases", () => {
    expect(fibonacci(5)).toBe(5);
    // => fib(5) = 5 (0,1,1,2,3,5)
    expect(fibonacci(10)).toBe(55);
    // => fib(10) = 55
  });
 
  test("isPrime identifies primes", () => {
    expect(isPrime(2)).toBe(true);
    // => 2 is prime
    expect(isPrime(7)).toBe(true);
    // => 7 is prime
    expect(isPrime(4)).toBe(false);
    // => 4 is not prime (divisible by 2)
  });
}

Key Takeaway: Wrap in-source tests in if (import.meta.vitest) to co-locate tests with code. Configure define: { 'import.meta.vitest': 'undefined' } in production build to tree-shake tests.

Why It Matters: In-source testing provides the tightest feedback loop -- tests live next to the code they verify, eliminating the context switch of navigating to a separate test file. Vite tree-shakes the test code from production builds, so there's no bundle size penalty. This pattern is especially valuable for utility functions and pure logic where the test is as simple as the implementation. It also ensures 100% co-location -- impossible for a function to lack tests when they share a file.


Example 48: Workspace Configuration

Vitest workspaces enable running tests across multiple projects with different configurations in a monorepo.

// vitest.workspace.ts - Workspace configuration
// (Configuration file, not a test)
 
// export default [
//   'packages/*',
//   // => Each package directory is a project
//   // => Uses each package's vitest.config.ts
//
//   {
//     extends: './vitest.config.ts',
//     test: {
//       name: 'unit',
//       include: ['src/**/*.unit.test.ts'],
//       environment: 'node',
//     },
//   },
//   // => Named project "unit" with node environment
//
//   {
//     extends: './vitest.config.ts',
//     test: {
//       name: 'browser',
//       include: ['src/**/*.browser.test.ts'],
//       environment: 'happy-dom',
//     },
//   },
//   // => Named project "browser" with happy-dom
// ];
 
// Self-contained workspace concept demonstration
import { test, expect } from "vitest";
 
const workspaceConfig = {
  projects: [
    { name: "unit", environment: "node", include: ["**/*.unit.test.ts"] },
    {
      name: "browser",
      environment: "happy-dom",
      include: ["**/*.browser.test.ts"],
    },
    {
      name: "integration",
      environment: "node",
      include: ["**/*.integration.test.ts"],
    },
  ],
};
// => Workspace defines multiple test projects
 
test("workspace has multiple projects", () => {
  expect(workspaceConfig.projects).toHaveLength(3);
  // => Three projects: unit, browser, integration
});
 
test("each project has different environment", () => {
  const environments = workspaceConfig.projects.map((p) => p.environment);
  // => ["node", "happy-dom", "node"]
 
  expect(environments).toContain("happy-dom");
  // => Browser tests use DOM environment
  expect(environments.filter((e) => e === "node")).toHaveLength(2);
  // => Two projects use node environment
});

Key Takeaway: Define vitest.workspace.ts to run multiple test projects with different configurations. Each project can have its own environment, include patterns, and settings.

Why It Matters: Monorepos contain packages with different testing needs -- a UI library needs happy-dom, an API package needs node, a shared utilities package needs both. Without workspaces, you maintain separate Vitest configs and run commands per package. Workspaces let npx vitest discover and run all projects with appropriate configurations, providing unified reporting and parallel execution across the entire monorepo from a single command.


Example 49: Test Filtering and Reporters

Vitest provides multiple reporter formats for different output needs -- terminal development, CI integration, and HTML reporting.

// vitest.config.ts reporter configuration
// test: {
//   reporters: ['default', 'json', 'junit'],
//   // => default: colored terminal output
//   // => json: machine-readable JSON
//   // => junit: XML for CI systems (Jenkins, GitLab)
//   outputFile: {
//     json: './test-results/results.json',
//     junit: './test-results/results.xml',
//   },
// }
 
import { describe, it, expect } from "vitest";
 
// Filtering examples (CLI commands):
// npx vitest run --reporter=verbose
// => Detailed per-test output
// npx vitest run --reporter=dot
// => Minimal dot notation (. for pass, x for fail)
 
describe("reporter demonstration", () => {
  // These tests generate different output per reporter
 
  it("passes and shows in all reporters", () => {
    expect(true).toBe(true);
    // => Default: checkmark with test name
    // => Verbose: full path and duration
    // => Dot: single "."
    // => JSON: { name: "...", status: "passed" }
  });
 
  describe("nested group", () => {
    it("shows hierarchy in verbose reporter", () => {
      expect(1 + 1).toBe(2);
      // => Verbose: "reporter demonstration > nested group > shows hierarchy..."
      // => Default: indented under parent
    });
  });
 
  // Filtering by test name
  // npx vitest run -t "passes"
  // => Runs only tests with "passes" in the name
 
  // Filtering by file
  // npx vitest run reporter
  // => Runs files matching "reporter" pattern
 
  // Filtering by project (workspaces)
  // npx vitest run --project=unit
  // => Runs only the "unit" workspace project
});

Key Takeaway: Configure reporters in vitest.config.ts or via CLI flags. Use default for development, json/junit for CI integration, and verbose for debugging.

Why It Matters: CI systems need machine-readable test results to display in dashboards, track trends, and enforce quality gates. JUnit XML integrates with Jenkins, GitHub Actions, and GitLab CI for test result visualization. JSON output enables custom reporting tools and test analytics. The verbose reporter shows exact test hierarchy and duration, essential for identifying slow tests. Choosing the right reporter per environment optimizes both developer experience and CI visibility.


Example 50: Concurrent Tests

it.concurrent runs tests in parallel within a suite, reducing execution time for independent tests. Use carefully -- tests must be truly independent.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
    A["describe.concurrent"] --> B["Test 1<br/>#40;parallel#41;"]
    A --> C["Test 2<br/>#40;parallel#41;"]
    A --> D["Test 3<br/>#40;parallel#41;"]
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style D fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000

Code:

import { describe, it, expect } from "vitest";
 
// All tests in this suite run concurrently
describe.concurrent("parallel tests", () => {
  it("test 1 runs in parallel", async () => {
    // => Starts immediately, doesn't wait for other tests
    const result = await simulateWork("task-1", 50);
    // => Simulates 50ms of async work
    expect(result).toBe("task-1-done");
    // => Passes: work completed
  });
 
  it("test 2 runs in parallel", async () => {
    // => Starts at the same time as test 1
    const result = await simulateWork("task-2", 50);
    expect(result).toBe("task-2-done");
    // => Passes: concurrent execution
  });
 
  it("test 3 runs in parallel", async () => {
    // => All three tests execute simultaneously
    const result = await simulateWork("task-3", 50);
    expect(result).toBe("task-3-done");
    // => Total time: ~50ms (not 150ms)
  });
});
 
// Individual concurrent test
it.concurrent("standalone concurrent test", async () => {
  // => it.concurrent marks single test as concurrent
  const result = await simulateWork("standalone", 30);
  expect(result).toBe("standalone-done");
  // => Runs in parallel with other concurrent tests
});
 
async function simulateWork(task: string, ms: number): Promise<string> {
  // => Simulates async work with delay
  return new Promise((resolve) => {
    setTimeout(() => resolve(`${task}-done`), ms);
    // => Resolves after specified delay
  });
}

Key Takeaway: Use describe.concurrent or it.concurrent for tests that are truly independent and don't share mutable state. Concurrent tests reduce wall-clock time but require careful isolation.

Why It Matters: A suite of 100 async tests that each take 50ms runs in 5 seconds sequentially but ~50ms concurrently. This 100x speedup is real for integration tests that wait on I/O (mocked API calls, timer-based logic). However, concurrent tests sharing mutable state (global variables, module singletons, DOM elements) create race conditions that produce random failures. Use concurrency for pure async tests and sequential execution for tests with shared state.


Type Testing (Examples 51-53)

Example 51: expectTypeOf - Compile-Time Type Assertions

Vitest provides expectTypeOf for testing TypeScript types at compile time. These assertions verify your type definitions work correctly.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
    A["expectTypeOf#40;value#41;"] --> B["Compile-Time<br/>Type Check"]
    B --> C["toBeString#40;#41;"]
    B --> D["toBeNumber#40;#41;"]
    B --> E["toEqualTypeOf#60;T#62;#40;#41;"]
    B --> F["toHaveProperty#40;#41;"]
 
    style A fill:#0173B2,stroke:#000000,stroke-width:2px,color:#fff
    style B fill:#DE8F05,stroke:#000000,stroke-width:2px,color:#fff
    style C fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style D fill:#029E73,stroke:#000000,stroke-width:2px,color:#fff
    style E fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000
    style F fill:#CC78BC,stroke:#000000,stroke-width:2px,color:#000

Code:

import { test, expectTypeOf } from "vitest";
 
test("basic type assertions", () => {
  expectTypeOf("hello").toBeString();
  // => Passes at compile time: "hello" is string type
 
  expectTypeOf(42).toBeNumber();
  // => Passes: 42 is number type
 
  expectTypeOf(true).toBeBoolean();
  // => Passes: true is boolean type
 
  expectTypeOf(null).toBeNull();
  // => Passes: null is null type
 
  expectTypeOf(undefined).toBeUndefined();
  // => Passes: undefined is undefined type
 
  expectTypeOf([1, 2, 3]).toBeArray();
  // => Passes: array type
 
  expectTypeOf({ name: "Alice" }).toBeObject();
  // => Passes: object type
});
 
test("function type assertions", () => {
  function greet(name: string): string {
    return `Hello, ${name}`;
  }
 
  expectTypeOf(greet).toBeFunction();
  // => Passes: greet is a function
 
  expectTypeOf(greet).parameters.toEqualTypeOf<[string]>();
  // => Passes: parameter types match [string]
 
  expectTypeOf(greet).returns.toBeString();
  // => Passes: return type is string
});
 
test("generic type assertions", () => {
  type User = { name: string; age: number };
 
  expectTypeOf<User>().toHaveProperty("name");
  // => Passes: User type has "name" property
 
  expectTypeOf<User>().toHaveProperty("age");
  // => Passes: User type has "age" property
 
  expectTypeOf<User>().toMatchTypeOf<{ name: string }>();
  // => Passes: User extends { name: string }
});

Key Takeaway: Use expectTypeOf to verify type definitions, function signatures, and generic constraints at compile time. These tests catch type regressions without runtime execution.

Why It Matters: TypeScript types are code -- they define contracts, constrain inputs, and guide IDE auto-completion. When types change (intentionally or accidentally), runtime tests won't catch the regression because JavaScript has no types at runtime. expectTypeOf fills this gap by verifying that your exported types maintain their contracts across versions. This is critical for library authors whose users depend on stable type definitions.


Example 52: Complex Type Testing Patterns

Test union types, generics, and type narrowing to ensure your type definitions handle edge cases correctly.

import { test, expectTypeOf } from "vitest";
 
test("union type assertions", () => {
  type Result<T> = { success: true; data: T } | { success: false; error: string };
 
  // Verify the union type structure
  expectTypeOf<Result<string>>().toMatchTypeOf<{ success: boolean }>();
  // => Passes: both branches have success property
 
  // Verify specific branch types
  const success: Result<number> = { success: true, data: 42 };
  expectTypeOf(success).toMatchTypeOf<{ data: number }>();
  // => Passes: success branch has data property
 
  const failure: Result<number> = { success: false, error: "fail" };
  expectTypeOf(failure).toMatchTypeOf<{ error: string }>();
  // => Passes: failure branch has error property
});
 
test("generic constraint assertions", () => {
  // Verify generic function accepts correct types
  function identity<T>(value: T): T {
    return value;
  }
 
  expectTypeOf(identity<string>).returns.toBeString();
  // => Passes: identity<string> returns string
 
  expectTypeOf(identity<number>).returns.toBeNumber();
  // => Passes: identity<number> returns number
 
  expectTypeOf(identity).toBeCallableWith("hello");
  // => Passes: can call with string
  expectTypeOf(identity).toBeCallableWith(42);
  // => Passes: can call with number
});
 
test("mapped type assertions", () => {
  type Readonly<T> = { readonly [K in keyof T]: T[K] };
 
  type User = { name: string; age: number };
  type ReadonlyUser = Readonly<User>;
 
  expectTypeOf<ReadonlyUser>().toHaveProperty("name");
  // => Passes: mapped type preserves properties
  expectTypeOf<ReadonlyUser>().toMatchTypeOf<{ name: string }>();
  // => Passes: property types preserved
});

Key Takeaway: Test complex types (unions, generics, mapped types) to ensure type transformations produce expected results. expectTypeOf catches regressions in type utilities that runtime tests cannot detect.

Why It Matters: Type utilities like Result<T>, DeepPartial<T>, and custom mapped types are used throughout large codebases. A subtle change to a mapped type can break dozens of consumers. Type tests verify that Readonly<User> actually makes properties readonly, that Result<string> correctly narrows to success or failure branches, and that generic constraints accept expected types. Without these tests, type regressions are caught only when developers notice broken auto-completion.


Example 53: assertType for Runtime Type Narrowing

assertType combines compile-time type checking with runtime execution, useful for testing type guards and narrowing logic.

import { test, assertType, expectTypeOf } from "vitest";
 
test("type guard assertions", () => {
  // Type guard function
  function isString(value: unknown): value is string {
    return typeof value === "string";
    // => Narrows type to string when returns true
  }
 
  const value: unknown = "hello";
  // => value is unknown type
 
  if (isString(value)) {
    // => Inside this block, TypeScript knows value is string
    assertType<string>(value);
    // => assertType: compile-time assertion that value is string
    // => Does nothing at runtime (type-level only)
    // => Fails at compile time if type is wrong
 
    expectTypeOf(value).toBeString();
    // => Additional verification
  }
});
 
test("discriminated union narrowing", () => {
  type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
 
  function area(shape: Shape): number {
    switch (shape.kind) {
      case "circle":
        // => TypeScript narrows to { kind: "circle"; radius: number }
        assertType<{ kind: "circle"; radius: number }>(shape);
        return Math.PI * shape.radius ** 2;
      case "square":
        // => TypeScript narrows to { kind: "square"; side: number }
        assertType<{ kind: "square"; side: number }>(shape);
        return shape.side ** 2;
    }
  }
 
  // Runtime verification
  const circle: Shape = { kind: "circle", radius: 5 };
  expect(area(circle)).toBeCloseTo(78.54, 1);
  // => Passes: pi * 25 ≈ 78.54
 
  const square: Shape = { kind: "square", side: 4 };
  expect(area(square)).toBe(16);
  // => Passes: 4^2 = 16
});

Key Takeaway: Use assertType<T>() to verify type narrowing inside conditional blocks. Combine with runtime assertions for complete type guard testing.

Why It Matters: Type guards are the bridge between TypeScript's type system and runtime behavior. A type guard that returns true for the wrong types creates type-unsafe code that TypeScript trusts. Testing type guards with both assertType (compile-time) and expect (runtime) ensures the guard correctly narrows types AND correctly identifies values. This double verification catches bugs where a type guard's implementation doesn't match its type signature.


Custom Matchers (Examples 54-56)

Example 54: expect.extend - Creating Custom Matchers

expect.extend adds domain-specific matchers to Vitest's assertion library, enabling expressive tests that match your business vocabulary.

import { test, expect } from "vitest";
 
// Define custom matchers
expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    // => Custom matcher: checks if number is within range
    const pass = received >= floor && received <= ceiling;
    // => pass is true if within range
 
    if (pass) {
      return {
        message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
        // => Message shown when .not.toBeWithinRange fails
        pass: true,
        // => Indicates match succeeded
      };
    }
    return {
      message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
      // => Message shown when toBeWithinRange fails
      pass: false,
      // => Indicates match failed
    };
  },
});
 
// Type declaration for TypeScript
declare module "vitest" {
  interface Assertion<T = unknown> {
    toBeWithinRange(floor: number, ceiling: number): T;
  }
  interface AsymmetricMatchersContaining {
    toBeWithinRange(floor: number, ceiling: number): unknown;
  }
}
 
test("custom matcher - toBeWithinRange", () => {
  expect(50).toBeWithinRange(1, 100);
  // => Passes: 50 is between 1 and 100
 
  expect(0).not.toBeWithinRange(1, 100);
  // => Passes: 0 is NOT between 1 and 100
 
  expect(100).toBeWithinRange(1, 100);
  // => Passes: 100 is within range (inclusive)
 
  // Practical use: API response time
  const responseTime = 245;
  // => Response time in milliseconds
  expect(responseTime).toBeWithinRange(0, 500);
  // => Passes: response within acceptable range
});

Key Takeaway: Use expect.extend to create domain-specific matchers with descriptive names and clear error messages. Declare types with declare module "vitest" for TypeScript support.

Why It Matters: Generic assertions like expect(x >= 1 && x <= 100).toBe(true) produce useless failure messages: "expected false to be true." Custom matchers produce "expected 150 to be within range 1 - 100" -- immediately diagnosable. Domain-specific matchers (toBeValidEmail, toBeISO8601, toBeWithinRange) make tests read like specifications and produce actionable failure messages, reducing debugging time from minutes to seconds.


Example 55: Custom Matchers for Objects

Create matchers that validate complex object structures using domain-specific rules.

import { test, expect } from "vitest";
 
expect.extend({
  toBeValidUser(received: unknown) {
    // => Custom matcher: validates user object structure
    const user = received as Record<string, unknown>;
    const errors: string[] = [];
    // => Collect all validation errors
 
    if (!user || typeof user !== "object") {
      errors.push("not an object");
    } else {
      if (typeof user.name !== "string" || (user.name as string).length === 0) {
        errors.push("name must be a non-empty string");
      }
      if (typeof user.email !== "string" || !(user.email as string).includes("@")) {
        errors.push("email must contain @");
      }
      if (typeof user.age !== "number" || (user.age as number) < 0) {
        errors.push("age must be a non-negative number");
      }
    }
 
    const pass = errors.length === 0;
    // => pass is true when no validation errors
 
    return {
      pass,
      message: () =>
        pass
          ? `expected value not to be a valid user`
          : `expected valid user but found errors:\n${errors.map((e) => `  - ${e}`).join("\n")}`,
      // => Detailed error messages list all failures
    };
  },
});
 
declare module "vitest" {
  interface Assertion<T = unknown> {
    toBeValidUser(): T;
  }
}
 
test("validates correct user", () => {
  const user = { name: "Alice", email: "alice@test.com", age: 30 };
  // => Valid user object
  expect(user).toBeValidUser();
  // => Passes: all fields valid
});
 
test("rejects invalid user", () => {
  const user = { name: "", email: "invalid", age: -1 };
  // => Invalid: empty name, no @, negative age
  expect(user).not.toBeValidUser();
  // => Passes: user is NOT valid
});
 
test("validates partial user fails", () => {
  const user = { name: "Bob" };
  // => Missing email and age
  expect(user).not.toBeValidUser();
  // => Passes: incomplete user is not valid
});

Key Takeaway: Custom object matchers collect all validation errors and present them in the failure message, making complex object validation readable and debuggable.

Why It Matters: API endpoint tests frequently validate response objects with multiple required fields. Without custom matchers, you write separate assertions for each field, and the test stops at the first failure. A custom toBeValidUser matcher checks all fields and reports all errors at once, reducing the "fix one field, run again, fix next field" cycle. This pattern scales to any domain object -- orders, transactions, configurations -- where validation logic is complex.


Example 56: Asymmetric Custom Matchers

Custom matchers work as asymmetric matchers inside toEqual, enabling flexible partial matching with domain logic.

import { test, expect } from "vitest";
 
expect.extend({
  toBePositive(received: number) {
    // => Custom matcher: checks if number is positive
    return {
      pass: typeof received === "number" && received > 0,
      message: () => `expected ${received} to be a positive number`,
    };
  },
  toBeDateString(received: string) {
    // => Custom matcher: validates ISO date string format
    const dateRegex = /^\d{4}-\d{2}-\d{2}/;
    return {
      pass: typeof received === "string" && dateRegex.test(received),
      message: () => `expected "${received}" to be an ISO date string`,
    };
  },
});
 
declare module "vitest" {
  interface Assertion<T = unknown> {
    toBePositive(): T;
    toBeDateString(): T;
  }
  interface AsymmetricMatchersContaining {
    toBePositive(): unknown;
    toBeDateString(): unknown;
  }
}
 
test("custom matchers as asymmetric matchers", () => {
  const order = {
    id: 42,
    amount: 99.99,
    createdAt: "2026-04-05T10:30:00Z",
    items: [{ product: "Widget", quantity: 3, price: 33.33 }],
  };
 
  expect(order).toEqual({
    id: expect.toBePositive(),
    // => id must be a positive number (any positive value)
    amount: expect.toBePositive(),
    // => amount must be positive
    createdAt: expect.toBeDateString(),
    // => createdAt must match ISO date format
    items: expect.arrayContaining([
      expect.objectContaining({
        product: expect.any(String),
        quantity: expect.toBePositive(),
        price: expect.toBePositive(),
      }),
    ]),
    // => Items array contains at least one valid item
  });
  // => Passes: all custom and built-in matchers satisfied
});

Key Takeaway: Register custom matchers in AsymmetricMatchersContaining to use them inside toEqual with expect.matcherName() syntax. This combines domain validation with structural matching.

Why It Matters: Asymmetric custom matchers transform toEqual from a structural comparison tool into a validation engine. Instead of writing separate assertions for "id is positive," "date is ISO format," and "price is positive," you express all validation rules in a single toEqual call that matches the object's shape. This produces a single, readable assertion that validates both structure and domain rules, with failure messages that identify exactly which field and validation failed.


Advanced Organization (Examples 57-58)

Example 57: Test Context and Fixtures

Vitest provides test context for sharing utilities and state within a test suite, and supports extending the context with custom fixtures.

import { describe, it, expect, beforeEach } from "vitest";
 
// Using test context (this-like access)
describe("test context", () => {
  interface TestContext {
    db: { query: (sql: string) => string[] };
    timestamp: number;
  }
 
  beforeEach<TestContext>((context) => {
    // => context parameter provides per-test state
    context.db = {
      query: (sql: string) => [`result: ${sql}`],
    };
    // => Set up database mock in context
    context.timestamp = Date.now();
    // => Record test start time
  });
 
  it<TestContext>("accesses context properties", (context) => {
    // => context carries values from beforeEach
    const results = context.db.query("SELECT 1");
    // => Uses context.db for queries
 
    expect(results).toHaveLength(1);
    // => Passes: query returned one result
    expect(results[0]).toContain("SELECT 1");
    // => Passes: result contains SQL
    expect(context.timestamp).toBeLessThanOrEqual(Date.now());
    // => Passes: timestamp is in the past
  });
 
  it<TestContext>("gets fresh context each test", (context) => {
    // => Fresh context from beforeEach
    expect(context.db).toBeDefined();
    // => Passes: db set up for this test
    expect(context.timestamp).toBeGreaterThan(0);
    // => Passes: timestamp is valid
  });
});

Key Takeaway: Vitest test context passes setup values from lifecycle hooks to tests via a typed context parameter. This provides dependency injection without closures or global variables.

Why It Matters: Closures (let db at describe level) work for simple cases but become unwieldy with many shared resources -- each resource needs a let declaration and beforeEach assignment. Test context bundles all per-test resources into a single typed object, making it clear what each test receives. The TypeScript generics (it<TestContext>) ensure context properties are type-checked, preventing typos and undefined access that closures don't catch.


Example 58: Test Retry Configuration

Vitest supports retrying flaky tests a configurable number of times. Use retries sparingly -- they mask real issues but provide pragmatic stability for inherently flaky operations.

import { describe, it, expect } from "vitest";
 
// Configure retries globally in vitest.config.ts:
// test: { retry: 2 }
// => Retry failed tests up to 2 additional times
 
// Or per-test retry:
describe("retry configuration", () => {
  let attemptCount = 0;
  // => Track attempts for demonstration
 
  it(
    "retries on failure",
    () => {
      attemptCount++;
      // => Increment on each attempt
 
      // Simulates a test that fails initially, then passes
      if (attemptCount < 3) {
        throw new Error(`Attempt ${attemptCount} failed`);
        // => Fails on attempts 1 and 2
      }
 
      expect(attemptCount).toBe(3);
      // => Passes on third attempt
    },
    { retry: 3 },
    // => Retry up to 3 times for this specific test
    // => Total attempts: 1 initial + 3 retries = 4 max
  );
});
 
// Practical retry patterns
describe("practical retries", () => {
  it(
    "handles flaky network test",
    async () => {
      // Simulated network call that occasionally fails
      const random = Math.random();
      // => Random value between 0 and 1
 
      if (random < 0.3) {
        throw new Error("Transient network error");
        // => 30% chance of failure
      }
 
      expect(random).toBeGreaterThanOrEqual(0.3);
      // => Passes when random >= 0.3
    },
    { retry: 2 },
    // => Allow 2 retries for transient failures
    // => With retry=2: probability of all 3 attempts failing
    // => is 0.3^3 = 2.7% (down from 30%)
  );
});

Key Takeaway: Configure retries with { retry: N } per test or globally in config. Use retries only for genuinely flaky operations (network, timing), not as a band-aid for broken tests.

Why It Matters: Some tests are inherently non-deterministic -- race conditions in concurrent code, network timeouts, browser rendering timing. Retries reduce false failures in CI without hiding real bugs. However, overusing retries masks actual problems. A test that needs 3 retries to pass likely has a design issue. The pragmatic approach is retry for known flaky operations (external APIs, browser animations) and fix the root cause for deterministic test failures. CI pipelines typically allow 1-2 retries globally as a stability measure.

Last updated April 4, 2026

Command Palette

Search for a command to run...