Writing React components is only part of building a reliable application.
Imagine you have built a shopping application with:
- A product list.
- A search field.
- A shopping cart.
- User authentication.
- Checkout functionality.
- API integration.
Everything works perfectly during development.
Then you make a small change to the cart logic, and suddenly the checkout page stops working.
How can you catch problems like this before your users do?
The answer is testing.
Testing helps you verify that your application behaves as expected, detect bugs earlier, and make changes with confidence.
In this article, we'll learn how to test React applications using modern testing tools and practical examples.
We'll cover:
- Unit testing.
- Integration testing.
- React Testing Library.
- Vitest.
- User interactions.
- API mocking.
- Asynchronous UI testing.
- End-to-end testing with Playwright.
- Common testing mistakes.
- A practical testing strategy.
Let's get started!
1. Why Is Testing Important?
Testing is not just about finding bugs.
It also helps you maintain your application as it grows.
Imagine you have a function that calculates the total price of a shopping cart.
function calculateTotal(products) { return products.reduce( (total, product) => total + product.price * product.quantity, 0 ); } You know it works for your current data.
But what happens when:
- The cart is empty?
- A product has a quantity of zero?
- A product has a decimal price?
- Someone changes the calculation later?
Without tests, you may need to manually check every scenario.
With tests, you can verify expected behavior automatically.
Benefits of Testing
- Catch bugs early: Find problems before they reach production.
- Prevent regressions: Make sure existing functionality continues working.
- Improve code design: Testable code often has clearer responsibilities.
- Refactor safely: Change implementation without accidentally breaking behavior.
- Document behavior: Tests show how components and functions are expected to work.
- Build confidence: Make releases with fewer surprises.
Testing doesn't guarantee that an application is bug-free, but it significantly improves reliability.
2. Types of Testing
There are several levels of testing.
The most common categories are unit, integration, and end-to-end testing.
A. Unit Testing
Unit tests verify small, isolated pieces of functionality.
Examples:
- A utility function.
- A price calculator.
- A date formatter.
- A validation function.
- A small component's behavior.
Example:
function add(a, b) { return a + b; } A unit test might verify:
expect(add(2, 3)).toBe(5); The goal is to verify one small unit independently.
B. Integration Testing
Integration tests verify that multiple parts work together.
Examples:
- A form and its validation logic.
- A product list and a search field.
- A component and its API interaction.
- A shopping cart and its total calculation.
A React integration test might:
- Render a product list.
- Type into the search field.
- Verify that matching products appear.
- Verify that unrelated products disappear.
This tests how different parts of the application interact.
C. End-to-End Testing
End-to-end tests verify complete user journeys.
For example, a shopping application test might:
- Open the homepage.
- Search for a product.
- Open the product page.
- Add the product to the cart.
- Open the cart.
- Complete checkout.
These tests run against a real browser and exercise the application from the user's perspective.
Comparing the Three Types
| Test type | Main purpose | Typical scope |
|---|---|---|
| Unit | Verify isolated logic | Small |
| Integration | Verify connected parts | Medium |
| End-to-end | Verify complete workflows | Large |
All three are useful, but they have different costs and purposes.
3. The React Testing Tools
There are several tools commonly used in React projects.
Let's understand their responsibilities.
Vitest
Vitest is a JavaScript testing framework.
It provides:
- Test execution.
- Assertions.
- Mock functions.
- Setup and teardown hooks.
- Watch mode.
- Coverage integration.
It works particularly well with Vite projects.
React Testing Library
https://testing-library.com/docs/react-testing-library/intro/
React Testing Library helps you test React components through their rendered output.
Its philosophy is simple:
Test your application the way users interact with it.
Instead of checking internal implementation details, you interact with buttons, inputs, headings, and other visible elements.
jest-dom
https://github.com/testing-library/jest-dom
@testing-library/jest-dom provides useful DOM assertions.
For example:
expect(screen.getByRole("button")).toBeInTheDocument(); Or:
expect(screen.getByText("Welcome")).toBeVisible(); user-event
https://testing-library.com/docs/user-event/intro/
@testing-library/user-event simulates user interactions.
For example:
await user.click(button); await user.type(input, "React"); It generally provides a more realistic interaction model than directly dispatching individual DOM events.
Playwright
Playwright is an end-to-end testing framework.
It can:
- Open browsers.
- Navigate between pages.
- Click buttons.
- Fill inputs.
- Verify page content.
- Test multiple browser engines.
- Capture screenshots.
- Intercept network requests.
We'll use these tools in practical examples.
4. Setting Up a React Testing Environment
Let's create a React application using Vite.
npm create vite@latest react-testing-demo -- --template react cd react-testing-demo npm install Install the testing dependencies:
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event Your project will contain something similar to:
react-testing-demo/ | ├── src/ │ ├── App.jsx │ ├── main.jsx │ └── ... | ├── package.json └── vite.config.js Configure Vitest
Open vite.config.js:
import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], test: { environment: "jsdom", setupFiles: "./src/test/setup.js", globals: true, clearMocks: true, }, }); Here:
-
environment: "jsdom"provides a simulated browser DOM. -
setupFilesloads shared test configuration. -
globals: trueallows usingdescribe,it, andexpectwithout importing them in every test. -
clearMocks: trueclears mock call history between tests.
Create:
src/ └── test/ └── setup.js Inside setup.js:
import "@testing-library/jest-dom/vitest"; This enables additional DOM assertions.
Add Test Scripts
In package.json, add:
{ "scripts": { "dev": "vite", "build": "vite build", "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage" } } If you want coverage reports, install a coverage provider compatible with your Vitest version, such as @vitest/coverage-v8.
Now you can run:
npm test Or run tests once:
npm run test:run Your testing environment is ready.
5. Writing Your First Unit Test
Let's start with a simple utility function.
Create:
src/utils/calculateTotal.js export function calculateTotal(products) { return products.reduce( (total, product) => { return total + product.price * product.quantity; }, 0 ); } Now create:
src/utils/calculateTotal.test.js import { describe, it, expect } from "vitest"; import { calculateTotal } from "./calculateTotal"; describe("calculateTotal", () => { it("calculates the total price", () => { const products = [ { price: 10, quantity: 2 }, { price: 20, quantity: 1 }, ]; expect(calculateTotal(products)).toBe(40); }); it("returns zero for an empty cart", () => { expect(calculateTotal([])).toBe(0); }); it("handles decimal prices", () => { const products = [ { price: 9.99, quantity: 2 }, ]; expect(calculateTotal(products)).toBeCloseTo(19.98); }); }); Let's understand the structure.
describe
Groups related tests.
describe("calculateTotal", () => { // Tests go here }); it
Defines one test case.
it("calculates the total price", () => { // Test logic }); expect
Checks whether a value matches the expected result.
expect(result).toBe(40); Common Assertions
| Assertion | Purpose |
|---|---|
toBe() | Checks primitive equality or reference identity |
toEqual() | Checks deep equality |
toBeTruthy() | Checks whether a value is truthy |
toBeFalsy() | Checks whether a value is falsy |
toContain() | Checks whether an item exists |
toHaveLength() | Checks collection length |
toThrow() | Checks whether a function throws |
toBeCloseTo() | Compares floating-point numbers |
You can now test utility functions independently from React.
6. Testing React Components
Let's test a simple counter component.
Create:
src/components/Counter.jsx import { useState } from "react"; export default function Counter() { const [count, setCount] = useState(0); return ( <div> <h1>Count: {count}</h1> <button onClick={() => setCount((current) => current + 1)} > Increment </button> </div> ); } Now create:
src/components/Counter.test.jsx import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import Counter from "./Counter"; describe("Counter", () => { it("renders the initial count", () => { render(<Counter />); expect( screen.getByRole("heading", { name: "Count: 0", }) ).toBeInTheDocument(); }); it("increments the count when clicked", async () => { const user = userEvent.setup(); render(<Counter />); const button = screen.getByRole("button", { name: "Increment", }); await user.click(button); expect( screen.getByRole("heading", { name: "Count: 1", }) ).toBeInTheDocument(); }); }); What Is render()?
render(<Counter />); This renders the component into the test DOM.
What Is screen?
screen provides queries for finding elements in the rendered document.
For example:
screen.getByRole("button"); screen.getByText("Hello"); screen.getByLabelText("Email"); What Is userEvent?
const user = userEvent.setup(); await user.click(button); This simulates a user clicking the button.
The test verifies the visible behavior rather than checking the component's internal state.
That is the central idea behind React Testing Library.
7. Querying Elements the Right Way
React Testing Library provides several query methods.
Understanding their differences is important.
getBy
Use getBy when the element should exist immediately.
screen.getByRole("button", { name: "Submit", }); If the element does not exist, the test fails immediately.
queryBy
Use queryBy when the element may not exist.
expect( screen.queryByText("Error") ).not.toBeInTheDocument(); It returns null if no matching element exists.
findBy
Use findBy when an element is expected to appear asynchronously.
const message = await screen.findByText( "Data loaded" ); It waits for the element to appear, up to the configured timeout.
Query Priority
Prefer accessible queries:
getByRolegetByLabelTextgetByPlaceholderTextgetByTextgetByTestId
For example:
<label htmlFor="email">Email</label> <input id="email" type="email" /> Test it with:
screen.getByRole("textbox", { name: "Email", }); This encourages accessible interfaces and makes tests closer to real user interactions.
Avoid relying on implementation-specific selectors when an accessible query is available.
8. Testing Forms
Forms are common sources of bugs.
Let's build a login form.
import { useState } from "react"; export default function LoginForm({ onSubmit }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); function handleSubmit(event) { event.preventDefault(); if (!email || !password) { return; } onSubmit({ email, password, }); } return ( <form onSubmit={handleSubmit}> <label htmlFor="email">Email</label> <input id="email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} /> <label htmlFor="password">Password</label> <input id="password" type="password" value={password} onChange={(event) => setPassword(event.target.value)} /> <button type="submit"> Login </button> </form> ); } Now test it:
import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import LoginForm from "./LoginForm"; describe("LoginForm", () => { it("submits email and password", async () => { const user = userEvent.setup(); const handleSubmit = vi.fn(); render( <LoginForm onSubmit={handleSubmit} /> ); await user.type( screen.getByRole("textbox", { name: "Email", }), "alex@example.com" ); await user.type( screen.getByLabelText("Password"), "secret123" ); await user.click( screen.getByRole("button", { name: "Login", }) ); expect(handleSubmit).toHaveBeenCalledWith({ email: "alex@example.com", password: "secret123", }); }); it("does not submit when fields are empty", async () => { const user = userEvent.setup(); const handleSubmit = vi.fn(); render( <LoginForm onSubmit={handleSubmit} /> ); await user.click( screen.getByRole("button", { name: "Login", }) ); expect(handleSubmit).not.toHaveBeenCalled(); }); }); What Is vi.fn()?
Vitest provides vi.fn() to create mock functions.
A mock function allows you to inspect:
- Whether it was called.
- How many times it was called.
- Which arguments it received.
For example:
expect(handleSubmit).toHaveBeenCalledTimes(1); Mock functions are extremely useful when testing callbacks and component interactions.
9. Testing Conditional Rendering
React applications often display different UI depending on state.
For example:
function Notification({ message }) { if (!message) { return null; } return ( <div role="alert"> {message} </div> ); } export default Notification; Test both states:
import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import Notification from "./Notification"; describe("Notification", () => { Source:https://dev.to/hamedfarazi/react-mastery-series-part-8-react-testing-unit-integration-end-to-end-testing-214kRelated posts
Migrating from Third-Party Page Builders to Native Magento Page Builder: A Developer’s Use Case Breakdown
Why Cursor is ignoring your rules (.mdc + frontmatter)

Promise.all Doesn't Limit Concurrency. Here's a Small Pool That Does.
