# Frontend Testing Strategy: From Unit Tests to End-to-End

Source: https://www.egnworks.com/blog/frontend-testing-strategy-from-unit-tests-to-end-to-end  
Author: Jacob Val  
Published: 2026-05-05  
Updated: 2026-05-05  
Category: Frontend Architecture  
Tags: Testing, React

> Learn the modern frontend testing strategy used by senior engineers. Covers unit testing, React Testing Library integration tests, Playwright E2E tests, MSW mocking, and CI pipeline setup for React applications.

---

## Introduction

Testing is the discipline that separates software that works from software that works reliably. In frontend development, the testing landscape has matured significantly. The tools are better, the patterns are clearer, and the community understanding of what to test and how to test it has converged around a set of principles that produce high-confidence test suites without the brittleness that plagued earlier approaches.

This guide covers the modern frontend testing strategy used by engineering teams that maintain large React codebases: what to test at each layer, how to write tests that survive refactoring, and how to build a testing workflow that catches real bugs without slowing down development.

## 1. The Testing Philosophy That Actually Works

The most influential principle in modern frontend testing comes from Kent C. Dodds: test the way your software is used. This means testing components and user interactions from the user's perspective, not from the implementation's perspective.

A test that verifies a component's internal state, class names, or implementation details is testing the wrong things. When the implementation changes during a refactor, these tests break even though the behavior is identical. This creates false failures and erodes trust in the test suite.

A test that verifies what the user sees and what happens when the user interacts with the UI survives refactoring naturally. The behavior is the contract. The implementation is irrelevant.

### The Testing Trophy

The testing trophy model, a refinement of the testing pyramid for frontend applications, distributes tests across four layers:

**Static analysis:** TypeScript, ESLint, and Prettier. These catch entire categories of bugs before tests even run and should be part of every CI pipeline.

**Unit tests:** Pure functions, hooks, and utility logic. Keep these fast and focused.

**Integration tests:** Components rendered with realistic data and interactions. This is where the majority of testing effort should be concentrated.

**End-to-end tests:** Critical user journeys through the full application. Fewer in number but high in confidence.

## 2. Unit Testing: Pure Logic First

Unit tests are most valuable for pure functions with deterministic inputs and outputs. Utility functions, data transformations, validation logic, and business rules are ideal candidates.

```ts
// utils/formatCurrency.ts
export function formatCurrency(
  amount: number,
  currency: string = "USD",
  locale: string  = "en-US"
): string {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
    minimumFractionDigits: 2,
  }).format(amount);
}
```

```
// utils/formatCurrency.test.ts
import { formatCurrency } from "./formatCurrency";

describe("formatCurrency", () => {
  it("formats USD correctly", () => {
    expect(formatCurrency(1234.5)).toBe("$1,234.50");
  });

  it("formats EUR with locale", () => {
    expect(formatCurrency(1234.5, "EUR", "de-DE")).toBe("1.234,50 €");
  });

  it("handles zero", () => {
    expect(formatCurrency(0)).toBe("$0.00");
  });

  it("handles negative amounts", () => {
    expect(formatCurrency(-50)).toBe("-$50.00");
  });
});
```

### Testing Custom Hooks

Custom hooks that encapsulate complex logic benefit from isolated unit testing using `renderHook` from React Testing Library.

```ts
// hooks/useCounter.ts
export function useCounter(initialValue: number = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  const reset     = () => setCount(initialValue);
  return { count, increment, decrement, reset };
}
```

```js
// hooks/useCounter.test.ts
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";

describe("useCounter", () => {
  it("initializes with the given value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it("increments the count", () => {
    const { result } = renderHook(() => useCounter());
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  it("resets to the initial value", () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.increment());
    act(() => result.current.reset());
    expect(result.current.count).toBe(5);
  });
});
```

## 3. Integration Testing with React Testing Library

Integration tests verify that components work correctly from the user's perspective. React Testing Library (RTL) is purpose-built for this approach. It renders components in a real DOM environment and provides queries that mirror how users find elements: by role, by label, by text, and by placeholder.

### Query Priority

RTL provides multiple ways to query elements. The priority from most to least preferred:

`getByRole`: finds elements by their ARIA role. This is the most accessible and most resilient query.

`getByLabelText`: finds form inputs by their associated label.

`getByPlaceholderText`: finds inputs by placeholder text.

`getByText`: finds elements by their text content.

`getByTestId`: finds elements by `data-testid`. Use this only as a last resort.

### Testing a Form Component

```jsx
// LoginForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";

describe("LoginForm", () => {
  it("submits the form with valid credentials", async () => {
    const user    = userEvent.setup();
    const onLogin = jest.fn();
    render(<LoginForm onLogin={onLogin} />);

    await user.type(screen.getByLabelText("Email"), "[email protected]");
    await user.type(screen.getByLabelText("Password"), "securepassword");
    await user.click(screen.getByRole("button", { name: "Sign in" }));

    await waitFor(() => {
      expect(onLogin).toHaveBeenCalledWith({
        email:    "[email protected]",
        password: "securepassword",
      });
    });
  });

  it("shows validation errors for empty fields", async () => {
    const user = userEvent.setup();
    render(<LoginForm onLogin={jest.fn()} />);

    await user.click(screen.getByRole("button", { name: "Sign in" }));

    expect(screen.getByText("Email is required")).toBeInTheDocument();
    expect(screen.getByText("Password is required")).toBeInTheDocument();
  });

  it("shows an error for invalid email format", async () => {
    const user = userEvent.setup();
    render(<LoginForm onLogin={jest.fn()} />);

    await user.type(screen.getByLabelText("Email"), "not-an-email");
    await user.click(screen.getByRole("button", { name: "Sign in" }));

    expect(screen.getByText("Enter a valid email address")).toBeInTheDocument();
  });
});
```

### Testing Async Data Fetching

```tsx
// UserProfile.test.tsx
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "@/mocks/server"; // MSW server
import { http, HttpResponse } from "msw";
import { UserProfile } from "./UserProfile";

function wrapper({ children }: { children: React.ReactNode }) {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

it("displays user information after loading", async () => {
  server.use(
    http.get("/api/users/1", () =>
      HttpResponse.json({ id: "1", name: "Alice Johnson", email: "[email protected]" })
    )
  );

  render(<UserProfile userId="1" />, { wrapper });

  expect(screen.getByRole("status")).toBeInTheDocument(); // loading state

  expect(await screen.findByText("Alice Johnson")).toBeInTheDocument();
  expect(screen.getByText("[email protected]")).toBeInTheDocument();
});

it("displays an error when the request fails", async () => {
  server.use(
    http.get("/api/users/1", () => new HttpResponse(null, { status: 500 }))
  );

  render(<UserProfile userId="1" />, { wrapper });

  expect(await screen.findByRole("alert")).toBeInTheDocument();
  expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
```

## 4. End-to-End Testing with Playwright

End-to-end tests run against the full application stack in a real browser. They are the highest-confidence tests but also the slowest and most expensive to maintain. Reserve them for critical user journeys: authentication, checkout, onboarding, and any flow where a failure has significant business impact.

```js
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("user can sign in with valid credentials", async ({ page }) => {
    await page.goto("/login");

    await page.getByLabel("Email").fill("[email protected]");
    await page.getByLabel("Password").fill("correctpassword");
    await page.getByRole("button", { name: "Sign in" }).click();

    await expect(page).toHaveURL("/dashboard");
    await expect(page.getByText("Welcome back")).toBeVisible();
  });

  test("shows error for invalid credentials", async ({ page }) => {
    await page.goto("/login");

    await page.getByLabel("Email").fill("[email protected]");
    await page.getByLabel("Password").fill("wrongpassword");
    await page.getByRole("button", { name: "Sign in" }).click();

    await expect(page.getByRole("alert")).toContainText(
      "Invalid email or password"
    );
    await expect(page).toHaveURL("/login");
  });

  test("redirects authenticated users to dashboard", async ({ page }) => {
    // Set auth cookie to simulate logged-in state
    await page.context().addCookies([
      { name: "auth_token", value: "valid_token", domain: "localhost" },
    ]);

    await page.goto("/login");
    await expect(page).toHaveURL("/dashboard");
  });
});
```

### Visual Regression Testing

Playwright's screenshot comparison feature catches unintended visual regressions in critical UI surfaces.

```js
test("dashboard matches visual snapshot", async ({ page }) => {
  await page.goto("/dashboard");
  await page.waitForLoadState("networkidle");
  await expect(page).toHaveScreenshot("dashboard.png", {
    maxDiffPixelRatio: 0.01,
  });
});
```

## 5. Setting Up a CI Testing Pipeline

Tests provide value only if they run consistently and their results are acted upon. A CI pipeline that runs on every pull request ensures that no breaking change reaches the main branch undetected.

```yaml
# .github/workflows/test.yml
name: Test

on:
  pull_request:
    branches: [main]

jobs:
  unit-and-integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run type-check
      - run: npm run lint
      - run: npm run test -- --coverage

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run build
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
```

## Conclusion

A modern frontend testing strategy is not about achieving 100 percent code coverage. It is about having confidence that the application behaves correctly for the people who use it. That confidence comes from integration tests that exercise real user interactions, unit tests that verify complex logic in isolation, and a small suite of end-to-end tests that cover the journeys where failures matter most.

Write tests that test behavior, not implementation. Use tools that reflect how users interact with software. Run tests in CI on every change. These three practices, consistently applied, produce a codebase that teams can refactor confidently and ship safely.

Tests are not overhead. They are the engineering discipline that makes everything else faster.
