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;
}
We verify:
expect(calculateTotal(100, 2)).toBe(200);
Unit Testing in React
We test:
- Rendering
- User interaction
- State changes
- DOM updates
Example:
render(<Counter />);
fireEvent.click(button);
expect(screen.getByText("1")).toBeInTheDocument();
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);
});
});
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
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"
}
}
Step 3 — Configure Vitest
Create:
vitest.config.js
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true
}
});
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 />);
screen
screen provides queries to find elements in the rendered DOM.
screen.getByText("Submit");
fireEvent
fireEvent simulates browser events like clicks and input changes.
fireEvent.click(button);
userEvent
userEvent simulates real user interactions more accurately than fireEvent.
await userEvent.click(button);
expect
expect verifies that the result matches the expected behavior.
expect(element).toBeInTheDocument();
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();
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;
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");
});
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();
});
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();
});
});
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();
});
Example 5 — Mocking an API Call
// api.js
export async function fetchUser() {
const response = await fetch("/api/user");
return response.json();
}
// 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;
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();
});
Example 6 — Testing Loading State
test("shows loading initially", () => {
render(<UserProfile />);
expect(
screen.getByText("Loading...")
).toBeInTheDocument();
});
Example 7 — Using afterEach and cleanup
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
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");
});
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()
);
});
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;
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"
);
});
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"
);
});
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"
);
});
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();
Avoid:
expect(component.state.isLoggedIn).toBe(true);
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", () => {})
Good:
test("shows error message when login fails", () => {})
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
Example Coverage Output
File % Stmts % Branch % Funcs % Lines
Counter 100 100 100 100
Coverage Folder
coverage/
lcov-report/
index.html
Open:
coverage/lcov-report/index.html
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)