| name | writing-test-using-vitest |
| description | Write Vitest unit tests and browser mode component tests for TypeScript projects. Use when asked to write tests, create test files, add test coverage, fix failing tests, test React components with browser mode, or work with Vitest testing patterns. Handles both unit testing with Given-When-Then pattern and React component testing with vitest-browser-react. |
Vitest TypeScript Testing
Core Testing Principles
Follow these fundamental patterns when writing Vitest tests:
Imports: Explicitly import all testing functions:
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
Test Structure: Use test() instead of it(). Organize with describe() blocks (max 4 levels). Structure tests using the Given-When-Then pattern:
describe("ComponentName", () => {
describe("method name", () => {
test("does something specific", () => {
// Given: Setup test data and preconditions
const input = createTestData();
// When: Execute the action being tested
const result = methodName(input);
// Then: Assert the expected outcome
expect(result).toStrictEqual(expected);
});
});
});
Assertions: Prefer single comprehensive assertions over multiple partial assertions:
// ✅ Good - single comprehensive assertion
expect(result).toStrictEqual({ id: 1, name: "test", active: true });
// ❌ Avoid - multiple partial assertions
expect(result.id).toBe(1);
expect(result.name).toBe("test");
expect(result.active).toBe(true);
Snapshots: Use toMatchInlineSnapshot() to verify values should not change unexpectedly:
expect(computed).toMatchInlineSnapshot(`
{
"key": "value",
}
`);
Update snapshots when values intentionally change:
vitest --update
File Location: Place test files next to implementation:
render.ts→render.test.tsin same directory
Prohibited: Never use test.skip(), test.only(), or test.todo() in test modifications. Tests must always run completely.
Unit Testing Patterns
Basic Unit Test Structure
import { describe, test, expect } from "vitest";
import { functionToTest } from "./module";
describe("functionToTest", () => {
test("returns expected result", () => {
const result = functionToTest("input");
expect(result).toStrictEqual({ output: "expected" });
});
});
Async Testing
Use resolves and rejects for promise assertions:
import { describe, test, expect } from "vitest";
describe("async function", () => {
test("resolves with correct value", async () => {
await expect(asyncFunction()).resolves.toEqual({ data: "value" });
});
test("rejects with error", async () => {
await expect(failingFunction()).rejects.toThrow("message");
});
});
Type Narrowing with Discriminated Unions
Use expect.unreachable() to narrow types safely with early return pattern:
import { describe, test, expect } from "vitest";
type Result =
| { type: "success"; data: string }
| { type: "error"; message: string };
describe("handleResult", () => {
test("handles success case", () => {
// Given
const result: Result = getResult();
// When/Then: Check for unexpected case first
if (result.type !== "success") {
expect.unreachable("Expected success result");
return;
}
// Now TypeScript knows result.type === "success"
expect(result.data).toBe("expected");
});
});
Critical: Never use conditional assertions without type narrowing. Always use expect.unreachable() with early return for discriminated union branches.
Mocking
Mocking should be used as a last resort. Before mocking, consider refactoring the implementation to make it more testable. If the implementation can be changed to be easier to test without mocks, suggest that refactoring instead.
Basic mocking example:
import { describe, test, expect, vi } from "vitest";
describe("with mocks", () => {
test("mocks function call", () => {
// Given
const mockFn = vi.fn();
// When
mockFn("arg");
// Then
expect(mockFn).toHaveBeenCalledWith("arg");
});
});
Browser Mode Component Testing
Setup
Browser mode tests require explicit import configuration:
import { describe, test, expect } from "vitest";
import { render } from "vitest-browser-react";
import { page, userEvent } from "vitest/browser";
Critical: Always use userEvent from vitest/browser for user interactions, not direct element methods.
Basic Component Test
import { describe, test, expect } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";
import { UserGreeting } from "./UserGreeting";
describe("UserGreeting", () => {
test("renders greeting with user name", async () => {
// Given
await render(<UserGreeting name="Alice" />);
// Then
await expect.element(page.getByText("Hello, Alice!")).toBeInTheDocument();
});
test("renders default greeting when no name provided", async () => {
// Given
await render(<UserGreeting />);
// Then
await expect.element(page.getByText("Hello, Guest!")).toBeInTheDocument();
});
});
Component Interaction Testing
import { describe, test, expect } from "vitest";
import { render } from "vitest-browser-react";
import { page, userEvent } from "vitest/browser";
describe("Counter", () => {
test("increments count on button click", async () => {
// Given
await render(<Counter initialCount={0} />);
await expect.element(page.getByText("Count: 0")).toBeInTheDocument();
// When
await userEvent.click(page.getByRole("button", { name: "Increment" }));
// Then
await expect.element(page.getByText("Count: 1")).toBeInTheDocument();
});
});
Form Testing
import { describe, test, expect } from "vitest";
import { render } from "vitest-browser-react";
import { page, userEvent } from "vitest/browser";
describe("LoginForm", () => {
test("submits with user input", async () => {
// Given
await render(<LoginForm />);
// When
await userEvent.fill(page.getByLabelText("Username"), "testuser");
await userEvent.fill(page.getByLabelText("Password"), "password123");
await userEvent.click(page.getByRole("button", { name: "Submit" }));
// Then
await expect
.element(page.getByText("Welcome testuser"))
.toBeInTheDocument();
});
});
Testing with Context Providers
import { describe, test, expect } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";
import { ThemeProvider } from "./ThemeProvider";
describe("ThemedButton", () => {
test("renders with theme", async () => {
await render(<ThemedButton>Click Me</ThemedButton>, {
wrapper: ({ children }) => (
<ThemeProvider theme="dark">{children}</ThemeProvider>
),
});
await expect
.element(page.getByRole("button"))
.toHaveAttribute("data-theme", "dark");
});
});
Hook Testing
import { describe, test, expect } from "vitest";
import { renderHook } from "vitest-browser-react";
describe("useCounter", () => {
test("increments counter", async () => {
const { result, act } = await renderHook(() => useCounter());
expect(result.current.count).toBe(0);
await act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
Common Patterns Reference
Hierarchical Test Organization
import { describe, test, expect } from "vitest";
describe("Calculator", () => {
describe("add", () => {
test("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
test("adds negative numbers", () => {
expect(add(-2, -3)).toBe(-5);
});
});
describe("subtract", () => {
test("subtracts numbers", () => {
expect(subtract(5, 3)).toBe(2);
});
});
});
Test Fixtures with test.extend
Prefer test.extend over beforeEach/afterEach for setup and teardown:
import { test as base, expect } from "vitest";
interface Fixtures {
testData: TestData;
}
const test = base.extend<Fixtures>({
testData: async ({}, use) => {
// Setup
const data = createTestData();
// Provide to test
await use(data);
// Teardown
cleanup(data);
},
});
describe("with fixtures", () => {
test("uses test data", ({ testData }) => {
expect(testData).toBeDefined();
});
});
Lint Error Resolution
If test code produces lint errors, resolve them before proceeding. Common fixes:
- Add missing imports
- Fix type errors
- Remove unused variables
- Correct assertion patterns