DEV Community

Cover image for Unit Testing in React Using Vitest — A Practical Guide
Alok Kumar
Alok Kumar

Posted on

Unit Testing in React Using Vitest — A Practical Guide

In the previous article, we explored the fundamentals of unit testing in JavaScript — what it is, why it matters, and how tools like Jest help automate testing.

In modern frontend development, however, we rarely work with standalone functions alone. We build user interfaces using frameworks like React, where behavior depends on rendering, state changes, user interactions, and asynchronous data.

This is where unit testing in React becomes essential.


What is Unit Testing in React?

Unit testing in React focuses on verifying that individual components behave correctly under different conditions. Instead of testing isolated functions only, we test how components render, respond to user interactions, and display data.

A React component can be considered a unit.

Examples of units in React:

  • A component
  • A custom hook
  • A utility function used inside a component
  • A form submission handler
  • A state update logic

In React, unit testing is less about internal implementation and more about observable behavior.

We test things like:

  • Does the component render correctly?
  • Does it display the right data?
  • Does it update the UI after an interaction?
  • Does it call a function when a button is clicked?
  • Does it handle loading and error states?

How Unit Testing in React Fits Next to Unit Testing in JavaScript

Unit testing in React builds directly on the same principles as unit testing in JavaScript.

The core idea remains unchanged:

Verify behavior in isolation.

But the type of behavior we verify is different.

Unit Testing in JavaScript

We test:

  • Functions
  • Logic
  • Calculations
  • Data transformations

Example:

function calculateTotal(price, quantity) {
  return price * quantity;
}
Enter fullscreen mode Exit fullscreen mode

We verify:

expect(calculateTotal(100, 2)).toBe(200);
Enter fullscreen mode Exit fullscreen mode

Unit Testing in React

We test:

  • Rendering
  • User interaction
  • State changes
  • DOM updates

Example:

render(<Counter />);

fireEvent.click(button);

expect(screen.getByText("1")).toBeInTheDocument();
Enter fullscreen mode Exit fullscreen mode

The Relationship

Think of it like this:

Unit Testing in JavaScript tests logic
Unit Testing in React tests behavior

Both follow the same testing principles:

  • Isolation
  • Predictability
  • Repeatability
  • Automation

React testing is simply the next layer built on top of JavaScript testing.


Vitest

Vitest is a fast, modern testing framework designed specifically for applications built with Vite. It provides a familiar API similar to Jest while offering better performance and seamless integration with modern frontend tooling.

Vitest is commonly used for:

  • React applications
  • Vite projects
  • Component testing
  • Unit testing
  • Integration testing

Why Vitest ?

Vitest solves several problems in modern frontend testing.

It provides:

  • Fast test execution
  • Native Vite integration
  • Built-in mocking
  • Watch mode
  • Code coverage
  • Snapshot testing
  • Parallel test execution

Most importantly, it allows developers to write tests using a syntax that feels very similar to Jest.

Example:

describe("Counter", () => {
  test("increments count", () => {
    expect(count).toBe(1);
  });
});
Enter fullscreen mode Exit fullscreen mode

If you already know Jest, learning Vitest is straightforward.


Setting Up Vitest in a React Project

Setting up Vitest in a React project is simple, especially when using Vite.

Step 1 — Install Dependencies

npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom
Enter fullscreen mode Exit fullscreen mode

These packages provide:

  • Vitest — test runner
  • Testing Library — component testing utilities
  • jest-dom — better DOM assertions
  • jsdom — browser environment for tests

Step 2 — Update package.json

{
  "scripts": {
    "test": "vitest",
    "test:watch": "vitest --watch",
    "test:coverage": "vitest --coverage"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — Configure Vitest

Create:

vitest.config.js
Enter fullscreen mode Exit fullscreen mode
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",
    globals: true
  }
});
Enter fullscreen mode Exit fullscreen mode

What to Test in React

When testing React components, we focus on behavior that users can observe.

Not implementation details.

What to Test

You should test:

  • Component rendering
  • User interactions
  • Conditional rendering
  • API responses
  • Form submission
  • Error handling
  • Loading states
  • Function calls

What NOT to Test

Avoid testing:

  • Internal state directly
  • Implementation details
  • Third-party libraries
  • Styling
  • Framework behavior

Testing Philosophy

Test the component the way a user interacts with it.

Not the way the code is written.


Core React Testing Examples

Before jumping into examples, let's briefly understand some commonly used testing terms. These utilities appear frequently when writing tests with Vitest and React Testing Library.

render
render mounts a React component into a simulated DOM so it can be tested.

render(<Component />);
Enter fullscreen mode Exit fullscreen mode

screen
screen provides queries to find elements in the rendered DOM.

screen.getByText("Submit");
Enter fullscreen mode Exit fullscreen mode

fireEvent
fireEvent simulates browser events like clicks and input changes.

fireEvent.click(button);
Enter fullscreen mode Exit fullscreen mode

userEvent
userEvent simulates real user interactions more accurately than fireEvent.

await userEvent.click(button);
Enter fullscreen mode Exit fullscreen mode

expect
expect verifies that the result matches the expected behavior.

expect(element).toBeInTheDocument();
Enter fullscreen mode Exit fullscreen mode

describe
describe groups related tests together.

test
test defines an individual test case.

beforeEach
beforeEach runs before every test.
Commonly used to render components or reset state.

afterEach
afterEach runs after every test.
Often used to clean up mocks.

cleanup
cleanup removes rendered components from the DOM after tests.
This prevents tests from interfering with each other.

vi.fn
Creates a mock function.

const mockFn = vi.fn();
Enter fullscreen mode Exit fullscreen mode

vi.mock
Replaces a module with a mock implementation.

Used for APIs and services.

Example Component — Counter

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  function handleIncrement() {
    setCount(count + 1);
  }

  return (
    <div>
      <h1 data-testid="count">{count}</h1>

      <button onClick={handleIncrement}>
        Increment
      </button>
    </div>
  );
}

export default Counter;
Enter fullscreen mode Exit fullscreen mode

Example 1 — Testing Component Rendering

import { render, screen } from "@testing-library/react";
import Counter from "./Counter";

test("renders initial count", () => {
  render(<Counter />);

  expect(
    screen.getByTestId("count")
  ).toHaveTextContent("0");
});
Enter fullscreen mode Exit fullscreen mode

Example 2 — Testing User Interaction

import { fireEvent } from "@testing-library/react";

test("increments count when button is clicked", () => {
  render(<Counter />);

  fireEvent.click(
    screen.getByRole("button", {
      name: /increment/i
    })
  );

  expect(
    screen.getByText("1")
  ).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Example 3 — Using beforeEach

import { render, screen } from "@testing-library/react";
import { beforeEach } from "vitest";

describe("Counter", () => {
  beforeEach(() => {
    render(<Counter />);
  });

  test("shows initial count", () => {
    expect(
      screen.getByText("0")
    ).toBeInTheDocument();
  });

  test("increments count", () => {
    fireEvent.click(
      screen.getByRole("button")
    );

    expect(
      screen.getByText("1")
    ).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

Example 4 — Testing Function Calls

import { vi } from "vitest";

test("calls handler when clicked", () => {
  const mockFn = vi.fn();

  render(
    <button onClick={mockFn}>
      Click
    </button>
  );

  fireEvent.click(
    screen.getByText("Click")
  );

  expect(mockFn).toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

Example 5 — Mocking an API Call

// api.js
export async function fetchUser() {
  const response = await fetch("/api/user");
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode
// UserProfile.jsx
import { useEffect, useState } from "react";
import { fetchUser } from "./api";

function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser().then(setUser);
  }, []);

  if (!user) return <p>Loading...</p>;

  return <h1>{user.name}</h1>;
}

export default UserProfile;
Enter fullscreen mode Exit fullscreen mode
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";

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

import { fetchUser } from "./api";

test("renders user name from API", async () => {
  fetchUser.mockResolvedValue({
    name: "John"
  });

  render(<UserProfile />);

  expect(
    await screen.findByText("John")
  ).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Example 6 — Testing Loading State

test("shows loading initially", () => {
  render(<UserProfile />);

  expect(
    screen.getByText("Loading...")
  ).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Example 7 — Using afterEach and cleanup

import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => {
  cleanup();
  vi.clearAllMocks();
});
Enter fullscreen mode Exit fullscreen mode

Example 8 — Using userEvent

import userEvent from "@testing-library/user-event";

test("user types into input", async () => {
  render(<input />);

  const input = screen.getByRole("textbox");

  await userEvent.type(
    input,
    "hello"
  );

  expect(input).toHaveValue("hello");
});
Enter fullscreen mode Exit fullscreen mode

Example 9 — Waiting for Async Updates

import { waitFor } from "@testing-library/react";

test("waits for element to appear", async () => {
  render(<UserProfile />);

  await waitFor(() =>
    expect(
      screen.getByText("John")
    ).toBeInTheDocument()
  );
});
Enter fullscreen mode Exit fullscreen mode

Example 10 — Testing Forms

Forms are one of the most important real-world testing scenarios because they involve user input, validation, and submission logic.

// LoginForm.jsx
import { useState } from "react";

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");

  function handleSubmit(e) {
    e.preventDefault();

    if (!email) {
      setError("Email is required");
      return;
    }

    setError("");
    onSubmit(email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">
        Email
      </label>

      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) =>
          setEmail(e.target.value)
        }
      />

      {error && (
        <p role="alert">
          {error}
        </p>
      )}

      <button type="submit">
        Submit
      </button>
    </form>
  );
}

export default LoginForm;
Enter fullscreen mode Exit fullscreen mode

Test — Input Change

test("updates input value when user types", async () => {
  render(<LoginForm onSubmit={() => {}} />);

  const input = screen.getByLabelText(
    /email/i
  );

  await userEvent.type(
    input,
    "test@example.com"
  );

  expect(input).toHaveValue(
    "test@example.com"
  );
});
Enter fullscreen mode Exit fullscreen mode

Test — Validation Error

test("shows error when form is submitted without email", async () => {
  render(<LoginForm onSubmit={() => {}} />);

  await userEvent.click(
    screen.getByRole("button", {
      name: /submit/i
    })
  );

  expect(
    screen.getByRole("alert")
  ).toHaveTextContent(
    "Email is required"
  );
});
Enter fullscreen mode Exit fullscreen mode

Test — Successful Submission

import { vi } from "vitest";

test("calls onSubmit with email", async () => {
  const mockSubmit = vi.fn();

  render(
    <LoginForm
      onSubmit={mockSubmit}
    />
  );

  const input = screen.getByLabelText(
    /email/i
  );

  await userEvent.type(
    input,
    "user@test.com"
  );

  await userEvent.click(
    screen.getByRole("button", {
      name: /submit/i
    })
  );

  expect(mockSubmit).toHaveBeenCalledWith(
    "user@test.com"
  );
});
Enter fullscreen mode Exit fullscreen mode

What These Examples Now Cover

This combined section now includes the core scenarios developers actually encounter:

  • Rendering components
  • User interaction
  • Lifecycle hooks
  • Function calls
  • Mocking APIs
  • Loading states
  • Async updates
  • Cleanup
  • Test isolation
  • Form validation
  • Form submission
  • User input handling

Best Practices for Testing React Components

Following best practices ensures tests remain reliable and maintainable.

Test Behavior, Not Implementation

Good:

expect(screen.getByText("Login")).toBeInTheDocument();
Enter fullscreen mode Exit fullscreen mode

Avoid:

expect(component.state.isLoggedIn).toBe(true);
Enter fullscreen mode Exit fullscreen mode

Keep Tests Independent

Each test should:

  • Run independently
  • Not rely on other tests
  • Not share state

Keep Tests Small
One test should verify one behavior.

Write Clear Test Names

Bad:

test("works", () => {})
Enter fullscreen mode Exit fullscreen mode

Good:

test("shows error message when login fails", () => {})
Enter fullscreen mode Exit fullscreen mode

Test Coverage in React with Vitest

Test coverage measures how much of your component code is executed during testing.

It helps identify untested logic.

Run Coverage

npm run test:coverage
Enter fullscreen mode Exit fullscreen mode

Example Coverage Output
File % Stmts % Branch % Funcs % Lines
Counter 100 100 100 100

Coverage Folder

coverage/
  lcov-report/
    index.html
Enter fullscreen mode Exit fullscreen mode

Open:

coverage/lcov-report/index.html
Enter fullscreen mode Exit fullscreen mode

to view a visual report.

Important Note

High coverage does not mean high-quality tests.

Coverage shows:
What was executed.
Not whether behavior was correctly verified.


Conclusion

Unit testing in React is a natural extension of unit testing in JavaScript. The principles remain the same — isolate behavior, verify outcomes, and build confidence in your code.

Tools like Vitest make it easier to test components quickly and reliably, while React Testing Library encourages testing from a user's perspective.

As applications grow, testing becomes less about preventing bugs and more about enabling safe changes. Good tests allow developers to refactor, add features, and deploy with confidence.

Write tests not just to validate code — but to support the long-term health of your application.

Top comments (0)