Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
26 minLesson 31 of 33
Testing & Deployment

Testing React with Vitest & Testing Library

Testing React with Vitest & Testing Library

Tests give you confidence to refactor, catch regressions before users do, and document how your components are supposed to behave. This lesson covers the modern React testing stack: Vitest as the test runner and React Testing Library as the component testing tool.

The Testing Philosophy

React Testing Library has a guiding principle: test what users see and do, not implementation details. A user doesn't care which state variable is set — they care whether the button appears, whether clicking it shows a message, whether the form validates correctly.

// ❌ Testing implementation (brittle — breaks on refactor)
expect(wrapper.state("isOpen")).toBe(true);
expect(component.find("DropdownMenu").props().isOpen).toBe(true);

// ✅ Testing behavior (resilient — focuses on what users experience)
expect(screen.getByRole("listbox")).toBeInTheDocument();

Setup

npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
    plugins: [react()],
    test: {
        environment: "jsdom",
        globals: true,
        setupFiles: "./src/test/setup.ts",
    },
});
// src/test/setup.ts
import "@testing-library/jest-dom";
// package.json
{
    "scripts": {
        "test": "vitest",
        "test:ui": "vitest --ui",
        "test:coverage": "vitest --coverage"
    }
}

Your First Test

// src/components/Button.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";
import { describe, it, expect, vi } from "vitest";

describe("Button", () => {
    it("renders the label", () => {
        render(<Button>Click me</Button>);
        expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
    });
    
    it("calls onClick when clicked", async () => {
        const handleClick = vi.fn();
        render(<Button onClick={handleClick}>Submit</Button>);
        
        await userEvent.click(screen.getByRole("button"));
        
        expect(handleClick).toHaveBeenCalledOnce();
    });
    
    it("is disabled when disabled prop is true", () => {
        render(<Button disabled>Save</Button>);
        expect(screen.getByRole("button")).toBeDisabled();
    });
    
    it("shows loading state", () => {
        render(<Button loading>Submit</Button>);
        expect(screen.getByText("Loading...")).toBeInTheDocument();
        expect(screen.getByRole("button")).toBeDisabled();
    });
});

Testing Forms

// src/components/LoginForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
import { vi } from "vitest";

describe("LoginForm", () => {
    it("shows validation errors for empty fields", async () => {
        render(<LoginForm onSubmit={vi.fn()} />);
        
        await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
        
        expect(screen.getByText("Email is required")).toBeInTheDocument();
        expect(screen.getByText("Password is required")).toBeInTheDocument();
    });
    
    it("shows error for invalid email", async () => {
        render(<LoginForm onSubmit={vi.fn()} />);
        
        await userEvent.type(screen.getByLabelText("Email"), "not-an-email");
        await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
        
        expect(screen.getByText("Valid email required")).toBeInTheDocument();
    });
    
    it("calls onSubmit with credentials", async () => {
        const handleSubmit = vi.fn();
        render(<LoginForm onSubmit={handleSubmit} />);
        
        await userEvent.type(screen.getByLabelText("Email"), "alice@example.com");
        await userEvent.type(screen.getByLabelText("Password"), "password123");
        await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
        
        expect(handleSubmit).toHaveBeenCalledWith({
            email: "alice@example.com",
            password: "password123",
        });
    });
    
    it("disables submit button while submitting", async () => {
        const handleSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)));
        render(<LoginForm onSubmit={handleSubmit} />);
        
        await userEvent.type(screen.getByLabelText("Email"), "alice@example.com");
        await userEvent.type(screen.getByLabelText("Password"), "password123");
        await userEvent.click(screen.getByRole("button", { name: "Sign in" }));
        
        expect(screen.getByRole("button", { name: /Signing in/i })).toBeDisabled();
    });
});

Testing Async Components

// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
import { vi } from "vitest";

// Mock fetch
vi.mock("@/lib/api", () => ({
    fetchUser: vi.fn(),
}));

import { fetchUser } from "@/lib/api";

describe("UserProfile", () => {
    it("shows loading state initially", () => {
        vi.mocked(fetchUser).mockResolvedValue({ name: "Alice", email: "alice@example.com" });
        render(<UserProfile userId="1" />);
        
        expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument();
    });
    
    it("displays user data after loading", async () => {
        vi.mocked(fetchUser).mockResolvedValue({ name: "Alice", email: "alice@example.com" });
        render(<UserProfile userId="1" />);
        
        await screen.findByText("Alice");   // findBy* waits for element to appear
        
        expect(screen.getByText("alice@example.com")).toBeInTheDocument();
    });
    
    it("shows error message when fetch fails", async () => {
        vi.mocked(fetchUser).mockRejectedValue(new Error("Not found"));
        render(<UserProfile userId="1" />);
        
        await screen.findByText(/failed to load/i);
    });
});

Testing with Context

When components use React Context, wrap them in the providers:

// src/test/helpers.tsx
import { render } from "@testing-library/react";
import { AuthProvider } from "@/context/auth";

function AllProviders({ children }: { children: React.ReactNode }) {
    return (
        <AuthProvider>
            {children}
        </AuthProvider>
    );
}

function customRender(ui: React.ReactElement, options = {}) {
    return render(ui, { wrapper: AllProviders, ...options });
}

export { customRender as render };

// Usage in tests
import { render } from "@/test/helpers";

Common Queries Reference

// getBy* — throws if not found (use for elements that must exist)
screen.getByRole("button", { name: "Submit" })
screen.getByLabelText("Email")
screen.getByPlaceholderText("Enter email")
screen.getByText("Hello World")
screen.getByTestId("submit-btn")

// queryBy* — returns null if not found (use for elements that might not exist)
const errorMsg = screen.queryByText("Error!");
expect(errorMsg).not.toBeInTheDocument();

// findBy* — async, waits for element to appear
const result = await screen.findByText("Loaded!");

// getAllBy* / queryAllBy* / findAllBy* — multiple elements
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(5);

What NOT to Test

  • Internal implementation details (state variable names, private methods)
  • Third-party library behavior (Shadcn, React Router)
  • Styling specifics (exact pixel values, Tailwind classes)
  • Simple prop passing (if your component just renders a prop, trust TypeScript)

Focus tests on: business logic, user interactions, edge cases, async behavior, and the most critical user flows.

Next lesson: Deploy to Vercel production — CI/CD setup, environment variables, and monitoring.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!