DEV Community

Cover image for React Mastery Series — Part 8: React Testing — Unit, Integration & End-to-End Testing
Hamed Farazi
Hamed Farazi

Posted on

React Mastery Series — Part 8: React Testing — Unit, Integration & End-to-End Testing

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Catch bugs early: Find problems before they reach production.
  2. Prevent regressions: Make sure existing functionality continues working.
  3. Improve code design: Testable code often has clearer responsibilities.
  4. Refactor safely: Change implementation without accidentally breaking behavior.
  5. Document behavior: Tests show how components and functions are expected to work.
  6. 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;
}
Enter fullscreen mode Exit fullscreen mode

A unit test might verify:

expect(add(2, 3)).toBe(5);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Render a product list.
  2. Type into the search field.
  3. Verify that matching products appear.
  4. 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:

  1. Open the homepage.
  2. Search for a product.
  3. Open the product page.
  4. Add the product to the cart.
  5. Open the cart.
  6. 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

https://vitest.dev/

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();
Enter fullscreen mode Exit fullscreen mode

Or:

expect(screen.getByText("Welcome")).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

It generally provides a more realistic interaction model than directly dispatching individual DOM events.

Playwright

https://playwright.dev/

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
Enter fullscreen mode Exit fullscreen mode

Install the testing dependencies:

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
Enter fullscreen mode Exit fullscreen mode

Your project will contain something similar to:

react-testing-demo/
|
├── src/
│   ├── App.jsx
│   ├── main.jsx
│   └── ...
|
├── package.json
└── vite.config.js
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

Here:

  • environment: "jsdom" provides a simulated browser DOM.
  • setupFiles loads shared test configuration.
  • globals: true allows using describe, it, and expect without importing them in every test.
  • clearMocks: true clears mock call history between tests.

Create:

src/
└── test/
    └── setup.js
Enter fullscreen mode Exit fullscreen mode

Inside setup.js:

import "@testing-library/jest-dom/vitest";
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or run tests once:

npm run test:run
Enter fullscreen mode Exit fullscreen mode

Your testing environment is ready.


5. Writing Your First Unit Test

Let's start with a simple utility function.

Create:

src/utils/calculateTotal.js
Enter fullscreen mode Exit fullscreen mode
export function calculateTotal(products) {
  return products.reduce(
    (total, product) => {
      return total + product.price * product.quantity;
    },
    0
  );
}
Enter fullscreen mode Exit fullscreen mode

Now create:

src/utils/calculateTotal.test.js
Enter fullscreen mode Exit fullscreen mode
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);
  });
});
Enter fullscreen mode Exit fullscreen mode

Let's understand the structure.

describe

Groups related tests.

describe("calculateTotal", () => {
  // Tests go here
});
Enter fullscreen mode Exit fullscreen mode

it

Defines one test case.

it("calculates the total price", () => {
  // Test logic
});
Enter fullscreen mode Exit fullscreen mode

expect

Checks whether a value matches the expected result.

expect(result).toBe(40);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now create:

src/components/Counter.test.jsx
Enter fullscreen mode Exit fullscreen mode
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();
  });
});
Enter fullscreen mode Exit fullscreen mode

What Is render()?

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

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");
Enter fullscreen mode Exit fullscreen mode

What Is userEvent?

const user = userEvent.setup();

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

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",
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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"
);
Enter fullscreen mode Exit fullscreen mode

It waits for the element to appear, up to the configured timeout.

Query Priority

Prefer accessible queries:

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByTestId

For example:

<label htmlFor="email">Email</label>

<input
  id="email"
  type="email"
/>
Enter fullscreen mode Exit fullscreen mode

Test it with:

screen.getByRole("textbox", {
  name: "Email",
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Test both states:

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

import Notification from "./Notification";

describe("Notification", () => {
  it("renders nothing when there is no message", () => {
    render(<Notification message="" />);

    expect(
      screen.queryByRole("alert")
    ).not.toBeInTheDocument();
  });

  it("displays the notification message", () => {
    render(
      <Notification message="Something went wrong" />
    );

    expect(
      screen.getByRole("alert")
    ).toHaveTextContent("Something went wrong");
  });
});
Enter fullscreen mode Exit fullscreen mode

This is useful for testing:

  • Error messages.
  • Empty states.
  • Loading indicators.
  • Success notifications.
  • Conditional buttons.
  • Permission-based UI.

10. Testing Asynchronous Components

Many React components depend on asynchronous data.

For example, a component may fetch users from an API.

Let's create a simple component.

import { useEffect, useState } from "react";

export default function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function loadUsers() {
      try {
        const response = await fetch("/api/users");

        if (!response.ok) {
          throw new Error("Failed to load users");
        }

        const data = await response.json();

        setUsers(data);
      } finally {
        setLoading(false);
      }
    }

    loadUsers();
  }, []);

  if (loading) {
    return <p>Loading users...</p>;
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

We want to test:

  1. The loading state.
  2. Successful data loading.
  3. Error behavior.

Mocking fetch

We can replace the global fetch function during a test.

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

import {
  describe,
  it,
  expect,
  vi,
} from "vitest";

import UserList from "./UserList";

describe("UserList", () => {
  it("shows users after loading", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => [
          { id: 1, name: "Alex" },
          { id: 2, name: "Jordan" },
        ],
      })
    );

    render(<UserList />);

    expect(
      screen.getByText("Loading users...")
    ).toBeInTheDocument();

    expect(
      await screen.findByText("Alex")
    ).toBeInTheDocument();

    expect(
      screen.getByText("Jordan")
    ).toBeInTheDocument();

    vi.unstubAllGlobals();
  });
});
Enter fullscreen mode Exit fullscreen mode

The important part is:

await screen.findByText("Alex");
Enter fullscreen mode Exit fullscreen mode

Because the data arrives asynchronously, we wait for the expected UI to appear.

A Better Pattern for Mock Cleanup

For larger test suites, put cleanup in shared setup or use afterEach.

For example:

import {
  afterEach,
  vi,
} from "vitest";

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

You should also test failure cases.

For example, a rejected request or a response with ok: false should result in a meaningful error state.

A production component should generally have an explicit error state rather than simply stopping the loading indicator.


11. Testing API Requests with Mock Service Worker

Mocking fetch directly is useful for simple examples.

However, when your application has many API interactions, a more scalable solution is Mock Service Worker (MSW).

Website:

https://mswjs.io/

MSW intercepts network requests and provides mock responses.

This allows your application to make requests normally while your tests control the server responses.

Install it:

npm install -D msw
Enter fullscreen mode Exit fullscreen mode

Create:

src/test/handlers.js
Enter fullscreen mode Exit fullscreen mode
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alex" },
      { id: 2, name: "Jordan" },
    ]);
  }),
];
Enter fullscreen mode Exit fullscreen mode

Create:

src/test/server.js
Enter fullscreen mode Exit fullscreen mode
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
Enter fullscreen mode Exit fullscreen mode

Update src/test/setup.js:

import "@testing-library/jest-dom/vitest";

import {
  afterAll,
  afterEach,
  beforeAll,
} from "vitest";

import { server } from "./server";

beforeAll(() => {
  server.listen({
    onUnhandledRequest: "error",
  });
});

afterEach(() => {
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});
Enter fullscreen mode Exit fullscreen mode

Now your tests can use the actual application request logic without manually replacing fetch in every test.

You can also override a response for a specific test:

import { http, HttpResponse } from "msw";

import { server } from "../test/server";

server.use(
  http.get("/api/users", () => {
    return HttpResponse.json(
      {
        message: "Server error",
      },
      {
        status: 500,
      }
    );
  })
);
Enter fullscreen mode Exit fullscreen mode

This makes it easier to test loading, success, and failure scenarios.


12. Testing Custom Hooks

React Testing Library provides renderHook for testing Hooks independently.

Imagine a custom Hook:

import { useState } from "react";

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  function increment() {
    setCount((current) => current + 1);
  }

  function decrement() {
    setCount((current) => current - 1);
  }

  return {
    count,
    increment,
    decrement,
  };
}
Enter fullscreen mode Exit fullscreen mode

Test it:

import {
  renderHook,
  act,
} from "@testing-library/react";

import {
  describe,
  it,
  expect,
} from "vitest";

import { useCounter } from "./useCounter";

describe("useCounter", () => {
  it("starts with the initial value", () => {
    const { result } = renderHook(() =>
      useCounter(5)
    );

    expect(result.current.count).toBe(5);
  });

  it("increments the count", () => {
    const { result } = renderHook(() =>
      useCounter(0)
    );

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it("decrements the count", () => {
    const { result } = renderHook(() =>
      useCounter(0)
    );

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(-1);
  });
});
Enter fullscreen mode Exit fullscreen mode

What Is act()?

act() ensures that React updates related to an interaction or state change have been processed before assertions are made.

Testing Library's user interaction helpers generally handle the required React update wrapping for you.

When directly invoking Hook methods or triggering updates manually, act() is useful.

Don't wrap every Testing Library operation in act() unnecessarily.


13. Testing Context Providers

React Context is often used for global application state.

Imagine a theme provider:

import {
  createContext,
  useContext,
  useState,
} from "react";

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");

  function toggleTheme() {
    setTheme((current) =>
      current === "light" ? "dark" : "light"
    );
  }

  return (
    <ThemeContext.Provider
      value={{
        theme,
        toggleTheme,
      }}
    >
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);

  if (!context) {
    throw new Error(
      "useTheme must be used within ThemeProvider"
    );
  }

  return context;
}
Enter fullscreen mode Exit fullscreen mode

Create a component:

import { useTheme } from "./ThemeContext";

export default function ThemeButton() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Test it:

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

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

import {
  describe,
  it,
  expect,
} from "vitest";

import {
  ThemeProvider,
} from "./ThemeContext";

import ThemeButton from "./ThemeButton";

describe("ThemeButton", () => {
  it("changes the theme when clicked", async () => {
    const user = userEvent.setup();

    render(
      <ThemeProvider>
        <ThemeButton />
      </ThemeProvider>
    );

    expect(
      screen.getByRole("button")
    ).toHaveTextContent("light");

    await user.click(
      screen.getByRole("button")
    );

    expect(
      screen.getByRole("button")
    ).toHaveTextContent("dark");
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing providers this way verifies the behavior of the actual Context integration.

For applications with several providers, you can create a reusable test wrapper.


14. Testing Components with Props

Let's test a product card.

export default function ProductCard({ product, onAdd }) {
  return (
    <article>
      <h2>{product.name}</h2>

      <p>${product.price}</p>

      <button onClick={() => onAdd(product)}>
        Add to Cart
      </button>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

Test:

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

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

import {
  describe,
  it,
  expect,
  vi,
} from "vitest";

import ProductCard from "./ProductCard";

describe("ProductCard", () => {
  it("displays product information", () => {
    const product = {
      id: 1,
      name: "Laptop",
      price: 1200,
    };

    render(
      <ProductCard
        product={product}
        onAdd={() => {}}
      />
    );

    expect(
      screen.getByRole("heading", {
        name: "Laptop",
      })
    ).toBeInTheDocument();

    expect(
      screen.getByText("$1200")
    ).toBeInTheDocument();
  });

  it("calls onAdd with the product", async () => {
    const user = userEvent.setup();

    const product = {
      id: 1,
      name: "Laptop",
      price: 1200,
    };

    const onAdd = vi.fn();

    render(
      <ProductCard
        product={product}
        onAdd={onAdd}
      />
    );

    await user.click(
      screen.getByRole("button", {
        name: "Add to Cart",
      })
    );

    expect(onAdd).toHaveBeenCalledWith(product);
  });
});
Enter fullscreen mode Exit fullscreen mode

This verifies both the component's displayed content and its interaction behavior.


15. What Is End-to-End Testing?

So far, we've mostly tested functions and React components.

But a real application consists of multiple connected systems:

  • Frontend.
  • Backend.
  • Database.
  • Authentication.
  • Routing.
  • Browser behavior.

End-to-end testing verifies complete user workflows.

For example:

Open application
      |
      v
Search for a product
      |
      v
Open product details
      |
      v
Add product to cart
      |
      v
Open shopping cart
      |
      v
Verify product and price
Enter fullscreen mode Exit fullscreen mode

This type of testing can catch problems that unit tests cannot.

A component may work perfectly in isolation while the complete application has a routing or integration bug.

That's why end-to-end tests are valuable.


16. Setting Up Playwright

Install Playwright:

npm install -D @playwright/test
Enter fullscreen mode Exit fullscreen mode

Install browser binaries:

npx playwright install
Enter fullscreen mode Exit fullscreen mode

Initialize the configuration:

npx playwright init
Enter fullscreen mode Exit fullscreen mode

Follow the setup prompts for your project.

A typical configuration might look like:

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",

  use: {
    baseURL: "http://localhost:5173",
    trace: "on-first-retry",
  },

  projects: [
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
      },
    },
  ],

  webServer: {
    command: "npm run dev -- --host 127.0.0.1",
    url: "http://127.0.0.1:5173",
    reuseExistingServer: !process.env.CI,
  },
});
Enter fullscreen mode Exit fullscreen mode

Depending on the Playwright version and initialization workflow, some configuration may already be generated for you.

Create:

e2e/
└── homepage.spec.js
Enter fullscreen mode Exit fullscreen mode

17. Writing Your First Playwright Test

Imagine your application displays a heading:

function App() {
  return (
    <main>
      <h1>Welcome to My Store</h1>

      <button>
        Explore Products
      </button>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create an end-to-end test:

import {
  test,
  expect,
} from "@playwright/test";

test("homepage displays the main heading", async ({ page }) => {
  await page.goto("/");

  await expect(
    page.getByRole("heading", {
      name: "Welcome to My Store",
    })
  ).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Let's understand the code.

page.goto()

Navigates to the application.

await page.goto("/");
Enter fullscreen mode Exit fullscreen mode

getByRole()

Finds an element using its accessible role and name.

page.getByRole("heading", {
  name: "Welcome to My Store",
});
Enter fullscreen mode Exit fullscreen mode

toBeVisible()

Checks that the element is visible.

await expect(element).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

Playwright automatically waits for many expected conditions, making tests more reliable than using arbitrary fixed delays.


18. Testing a Complete User Interaction

Let's test a search feature.

Imagine:

function SearchPage() {
  const products = [
    "Laptop",
    "Phone",
    "Keyboard",
  ];

  const [search, setSearch] = useState("");

  const filteredProducts = products.filter((product) =>
    product.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <main>
      <h1>Products</h1>

      <label htmlFor="search">
        Search products
      </label>

      <input
        id="search"
        value={search}
        onChange={(event) => setSearch(event.target.value)}
      />

      <ul>
        {filteredProducts.map((product) => (
          <li key={product}>
            {product}
          </li>
        ))}
      </ul>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

A Playwright test:

import {
  test,
  expect,
} from "@playwright/test";

test("user can search for products", async ({ page }) => {
  await page.goto("/");

  const searchInput = page.getByRole("textbox", {
    name: "Search products",
  });

  await searchInput.fill("Laptop");

  await expect(
    page.getByText("Laptop")
  ).toBeVisible();

  await expect(
    page.getByText("Phone")
  ).not.toBeVisible();

  await expect(
    page.getByText("Keyboard")
  ).not.toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

This verifies the actual browser experience.

It checks that a user can enter a search term and see the expected results.


19. Testing Navigation

Navigation is another important part of end-to-end testing.

Imagine:

function App() {
  return (
    <nav>
      <a href="/">Home</a>
      <a href="/about">About</a>
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode

Test navigation:

test("user can navigate to About", async ({ page }) => {
  await page.goto("/");

  await page.getByRole("link", {
    name: "About",
  }).click();

  await expect(page).toHaveURL(/about/);

  await expect(
    page.getByRole("heading", {
      name: "About Us",
    })
  ).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

This verifies that the link works and the destination page is displayed.

For client-side routing, make sure your development or test server is configured to serve the application entry point for application routes.


20. Testing API-Driven UI with Playwright

Playwright can intercept network requests.

This is useful when you want to test a frontend without depending on a live backend.

Example:

test("displays products returned by the API", async ({ page }) => {
  await page.route("**/api/products", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify([
        {
          id: 1,
          name: "Laptop",
          price: 1200,
        },
        {
          id: 2,
          name: "Phone",
          price: 800,
        },
      ]),
    });
  });

  await page.goto("/");

  await expect(
    page.getByText("Laptop")
  ).toBeVisible();

  await expect(
    page.getByText("Phone")
  ).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

You can also simulate a server failure:

await page.route("**/api/products", async (route) => {
  await route.fulfill({
    status: 500,
    contentType: "application/json",
    body: JSON.stringify({
      message: "Internal server error",
    }),
  });
});
Enter fullscreen mode Exit fullscreen mode

Then verify that the application displays a meaningful error message.

This makes it possible to test different network scenarios without requiring a real backend for every test.


21. Testing Loading, Error, and Empty States

A robust application should handle more than successful API responses.

Consider these scenarios:

Scenario Expected UI
Request is pending Loading indicator
Request succeeds Data is displayed
Request returns no items Empty state
Request fails Error message
User retries Request is attempted again

For example:

function ProductList({ products, loading, error }) {
  if (loading) {
    return <p role="status">Loading products...</p>;
  }

  if (error) {
    return <p role="alert">Unable to load products.</p>;
  }

  if (products.length === 0) {
    return <p>No products found.</p>;
  }

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.name}
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each state deserves a test.

For example:

it("shows the loading message", () => {
  render(
    <ProductList
      products={[]}
      loading={true}
      error={null}
    />
  );

  expect(
    screen.getByRole("status")
  ).toHaveTextContent("Loading products...");
});
Enter fullscreen mode Exit fullscreen mode

And:

it("shows the empty state", () => {
  render(
    <ProductList
      products={[]}
      loading={false}
      error={null}
    />
  );

  expect(
    screen.getByText("No products found.")
  ).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Thinking about different UI states helps prevent incomplete user experiences.


22. Test Coverage: How Much Testing Is Enough?

Test coverage measures how much of your code is executed by tests.

Common coverage metrics include:

  • Statement coverage.
  • Branch coverage.
  • Function coverage.
  • Line coverage.

For example, consider:

function getDiscount(price, isMember) {
  if (isMember) {
    return price * 0.9;
  }

  return price;
}
Enter fullscreen mode Exit fullscreen mode

A test for a member:

expect(getDiscount(100, true)).toBe(90);
Enter fullscreen mode Exit fullscreen mode

This doesn't exercise the non-member branch.

You need another test:

expect(getDiscount(100, false)).toBe(100);
Enter fullscreen mode Exit fullscreen mode

Is 100% Coverage Necessary?

Not always.

A project with 100% line coverage can still contain bugs.

Coverage tells you which code was executed, not whether every important behavior was verified correctly.

Focus on:

  • Business-critical logic.
  • Payment calculations.
  • Authentication.
  • Permissions.
  • Data transformations.
  • Error handling.
  • Important user workflows.

High coverage is useful, but meaningful tests are more important than chasing a percentage.


23. Common React Testing Mistakes

Let's look at mistakes that make tests unreliable or unnecessarily difficult.

Mistake 1: Testing Internal State

Avoid testing implementation details that users cannot observe.

Instead of checking a component's internal state, test the resulting UI.

Bad approach:

// Testing implementation-specific details
expect(componentState).toBe(1);
Enter fullscreen mode Exit fullscreen mode

Better:

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

Mistake 2: Using Test IDs Everywhere

This works:

<div data-testid="product-name">
  Laptop
</div>
Enter fullscreen mode Exit fullscreen mode

But prefer accessible queries when possible:

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

Or:

screen.getByRole("heading", {
  name: "Laptop",
});
Enter fullscreen mode Exit fullscreen mode

Test IDs are useful when there is no suitable accessible query, but they shouldn't be the default.

Mistake 3: Using Arbitrary Timeouts

Avoid:

await new Promise((resolve) => {
  setTimeout(resolve, 2000);
});
Enter fullscreen mode Exit fullscreen mode

This can make tests slow and unreliable.

Prefer:

await screen.findByText("Data loaded");
Enter fullscreen mode Exit fullscreen mode

Or use Playwright's automatic waiting assertions.

Mistake 4: Testing Too Many Details in One Test

A test should have a clear purpose.

Instead of testing loading, searching, sorting, and checkout in one enormous test, divide the behaviors into focused test cases.

Mistake 5: Ignoring Error States

Don't test only the happy path.

Test failed requests, invalid input, empty data, and unexpected conditions.

Mistake 6: Mocking Everything

Mocks are useful, but excessive mocking can make tests unrealistic.

Keep the important integration boundaries real when possible.

For example, React Testing Library tests can use the real component tree while MSW mocks the network boundary.

Mistake 7: Depending on Test Execution Order

Each test should establish its own required state.

Avoid making one test depend on another test having run first.


24. A Practical Testing Strategy for a React Project

You don't need to write hundreds of tests for every component.

Start by identifying the most important behavior.

For a shopping application, a practical strategy might look like this:

Unit Tests

Test:

  • Price calculations.
  • Discount calculations.
  • Form validation.
  • Data formatting.
  • Cart quantity logic.
  • Utility functions.

Integration Tests

Test:

  • Login form submission.
  • Product search.
  • Cart updates.
  • Context providers.
  • API loading and error states.
  • Form validation and error messages.

End-to-End Tests

Test:

  • User registration.
  • Login.
  • Product browsing.
  • Adding items to the cart.
  • Checkout flow.
  • Important navigation paths.

This gives you coverage at multiple levels without making every test unnecessarily expensive.

Example Project Structure

src/
|
├── components/
│   ├── Button/
│   │   ├── Button.jsx
│   │   └── Button.test.jsx
│   |
│   ├── ProductCard/
│   │   ├── ProductCard.jsx
│   │   └── ProductCard.test.jsx
│
├── features/
│   ├── cart/
│   │   ├── Cart.jsx
│   │   └── Cart.test.jsx
│   |
│   └── products/
│       ├── ProductList.jsx
│       └── ProductList.test.jsx
│
├── utils/
│   ├── calculateTotal.js
│   └── calculateTotal.test.js
│
└── test/
    ├── setup.js
    ├── handlers.js
    └── server.js

e2e/
├── homepage.spec.js
├── search.spec.js
└── checkout.spec.js
Enter fullscreen mode Exit fullscreen mode

Keeping tests close to the code they verify makes them easier to discover and maintain.


25. Running Tests in Continuous Integration

Testing is most useful when it happens regularly.

You can run tests manually, but a professional development workflow should also run them automatically.

For example, a GitHub Actions workflow can run tests whenever code is pushed or a pull request is opened.

Create:

.github/workflows/tests.yml
Enter fullscreen mode Exit fullscreen mode
name: React Tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run unit and integration tests
        run: npm run test:run

      - name: Build application
        run: npm run build
Enter fullscreen mode Exit fullscreen mode

This workflow:

  1. Checks out the repository.
  2. Sets up Node.js.
  3. Installs dependencies.
  4. Runs tests.
  5. Builds the application.

You can add a separate Playwright job for browser tests.

For production projects, pin and update Node.js and GitHub Actions versions according to your team's supported environment.

The important principle is simple:

A pull request should not be considered ready just because the code looks correct. It should also pass the project's automated checks.


26. Final Testing Checklist

Before shipping a React feature, ask yourself:

  • [ ] Does the important business logic have unit tests?
  • [ ] Are components tested through visible behavior?
  • [ ] Do forms have validation tests?
  • [ ] Are user interactions tested?
  • [ ] Are loading states tested?
  • [ ] Are error states tested?
  • [ ] Are empty states tested?
  • [ ] Are API responses mocked appropriately?
  • [ ] Are important workflows covered by end-to-end tests?
  • [ ] Are tests independent of execution order?
  • [ ] Are accessible queries used where possible?
  • [ ] Do automated tests run in CI?
  • [ ] Does the production build succeed?

Conclusion

Testing is a fundamental part of professional React development.

It helps you catch bugs, prevent regressions, and maintain confidence as your application grows.

The most important concepts from this article are:

  1. Unit tests verify isolated logic.
  2. Integration tests verify connected components and systems.
  3. End-to-end tests verify complete user workflows.
  4. Vitest provides a powerful test runner.
  5. React Testing Library focuses on user-visible behavior.
  6. MSW makes API testing more realistic.
  7. Playwright helps test real browser interactions.
  8. Coverage is useful, but meaningful assertions matter more.
  9. Automated CI checks help prevent broken code from reaching production.

Remember:

A good test doesn't care how your component works internally. It cares whether it behaves correctly for the user.

Start with important business logic, test the critical interactions, and gradually build a reliable testing strategy.


What's Next?

In Part 9 — React with TypeScript: Building Type-Safe Applications, we'll learn how to combine React with TypeScript.

We'll cover:

  • Why TypeScript matters in React.
  • Typing component props.
  • Typing state.
  • Event types.
  • Form handling.
  • Typing Hooks.
  • Context API with TypeScript.
  • API response types.
  • Generics in reusable components.
  • Common TypeScript mistakes.
  • Building a practical typed React feature.

See you in Part 9! 🚀

Top comments (0)