Skip to content
iD
InfoDive Labs
Back to blog
DevelopmentTestingQuality

Testing Strategies for Modern Web Applications

A comprehensive guide to testing modern web applications, covering unit testing, component testing, integration testing, and end-to-end testing with practical patterns and tooling advice.

July 18, 20246 min read

Testing is the practice that separates software that works from software that keeps working. In modern web applications built with component-based frameworks, server-rendered pages, and complex data flows, a thoughtful testing strategy is not optional. It is the foundation that allows teams to ship with confidence, refactor without fear, and scale without accumulating hidden bugs.

This guide presents a practical testing strategy for modern web applications, with specific tools, patterns, and priorities that deliver maximum confidence per hour of testing effort.

The Testing Pyramid, Adapted for Modern Web Apps

The traditional testing pyramid places unit tests at the base, integration tests in the middle, and end-to-end tests at the top. For modern component-driven web applications, a more effective model is the testing trophy, which emphasizes integration tests as the primary layer.

Here is the recommended distribution of effort:

  • Static analysis (TypeScript, ESLint): Catches type errors and common mistakes at zero runtime cost
  • Unit tests: For pure functions, utilities, and complex business logic
  • Component and integration tests: The largest and most valuable layer, testing components as users interact with them
  • End-to-end tests: For critical user journeys that span multiple pages and systems

The reasoning is straightforward: component tests with React Testing Library exercise the same code paths that users trigger, catch real bugs, run quickly, and are resilient to refactoring. They provide the best return on investment.

Unit Testing Pure Logic with Vitest

Unit tests are most valuable for pure functions and business logic that is independent of the UI framework. Vitest is the modern standard for JavaScript unit testing, offering Vite-powered speed and Jest-compatible APIs.

// lib/pricing.ts
export function calculatePrice(
  basePrice: number,
  quantity: number,
  discountTier: "none" | "silver" | "gold" | "platinum"
): number {
  const subtotal = basePrice * quantity;
  const discountRates = { none: 0, silver: 0.1, gold: 0.15, platinum: 0.2 };
  const discount = subtotal * discountRates[discountTier];
  return Math.round((subtotal - discount) * 100) / 100;
}
 
// lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { calculatePrice } from "./pricing";
 
describe("calculatePrice", () => {
  it("returns full price with no discount", () => {
    expect(calculatePrice(29.99, 3, "none")).toBe(89.97);
  });
 
  it("applies 10% discount for silver tier", () => {
    expect(calculatePrice(100, 2, "silver")).toBe(180);
  });
 
  it("applies 20% discount for platinum tier", () => {
    expect(calculatePrice(50, 4, "platinum")).toBe(160);
  });
 
  it("handles zero quantity", () => {
    expect(calculatePrice(100, 0, "gold")).toBe(0);
  });
 
  it("rounds to two decimal places", () => {
    expect(calculatePrice(33.33, 3, "silver")).toBe(89.99);
  });
});

Focus unit tests on functions with branching logic, edge cases, and precise numerical requirements. Avoid unit testing simple data transformations that are better verified through component tests that use them.

Component Testing with React Testing Library

Component tests should test behavior, not implementation. React Testing Library enforces this by providing an API that mirrors how users interact with your application: finding elements by their text, labels, and roles, then simulating clicks and typing.

// components/search-form.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { SearchForm } from "./search-form";
 
describe("SearchForm", () => {
  it("calls onSearch with the query after submission", async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
 
    render(<SearchForm onSearch={onSearch} />);
 
    const input = screen.getByRole("searchbox", { name: /search/i });
    await user.type(input, "react testing");
    await user.click(screen.getByRole("button", { name: /search/i }));
 
    expect(onSearch).toHaveBeenCalledWith("react testing");
  });
 
  it("shows validation error for empty submission", async () => {
    const user = userEvent.setup();
    render(<SearchForm onSearch={vi.fn()} />);
 
    await user.click(screen.getByRole("button", { name: /search/i }));
 
    expect(screen.getByText(/please enter a search term/i)).toBeInTheDocument();
  });
 
  it("debounces search suggestions while typing", async () => {
    const user = userEvent.setup();
    const onSuggest = vi.fn();
 
    render(<SearchForm onSearch={vi.fn()} onSuggest={onSuggest} />);
 
    const input = screen.getByRole("searchbox", { name: /search/i });
    await user.type(input, "rea");
 
    await waitFor(() => {
      expect(onSuggest).toHaveBeenCalledTimes(1);
      expect(onSuggest).toHaveBeenCalledWith("rea");
    });
  });
});

Notice how these tests never reference internal state, CSS classes, or implementation details. They describe what the user experiences. If you refactor the component's internals without changing behavior, these tests continue to pass.

Testing API Integration

Modern web applications rely heavily on API calls. Mock network requests at the network level using MSW (Mock Service Worker) rather than mocking fetch or axios directly. This approach tests your actual request code, including error handling and request formatting.

// mocks/handlers.ts
import { http, HttpResponse } from "msw";
 
export const handlers = [
  http.get("/api/products", ({ request }) => {
    const url = new URL(request.url);
    const category = url.searchParams.get("category");
 
    const products = category
      ? allProducts.filter((p) => p.category === category)
      : allProducts;
 
    return HttpResponse.json({ data: products });
  }),
 
  http.post("/api/orders", async ({ request }) => {
    const body = await request.json();
 
    if (!body.items || body.items.length === 0) {
      return HttpResponse.json(
        { error: "Order must contain at least one item" },
        { status: 400 }
      );
    }
 
    return HttpResponse.json(
      { id: "order_123", status: "confirmed" },
      { status: 201 }
    );
  }),
];
 
// mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
 
// vitest.setup.ts
import { beforeAll, afterEach, afterAll } from "vitest";
import { server } from "./mocks/server";
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

With MSW configured, your component tests exercise the full data flow from UI interaction through network request to rendered result, without hitting a real server.

End-to-End Testing with Playwright

End-to-end tests verify that your entire application works correctly from the user's perspective, including navigation, authentication, and cross-page data flow. Playwright is the leading E2E testing framework, offering multi-browser support, auto-waiting, and powerful debugging tools.

Reserve E2E tests for critical user journeys that generate revenue or are essential for user retention:

// e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";
 
test.describe("Checkout flow", () => {
  test("completes purchase with valid payment", async ({ page }) => {
    await page.goto("/products");
 
    // Add item to cart
    await page.getByRole("button", { name: "Add to cart" }).first().click();
    await expect(page.getByTestId("cart-count")).toHaveText("1");
 
    // Navigate to cart
    await page.getByRole("link", { name: "Cart" }).click();
    await expect(page).toHaveURL("/cart");
 
    // Proceed to checkout
    await page.getByRole("button", { name: "Checkout" }).click();
 
    // Fill shipping details
    await page.getByLabel("Full name").fill("Jane Smith");
    await page.getByLabel("Address").fill("123 Main St");
    await page.getByLabel("City").fill("San Francisco");
    await page.getByLabel("ZIP code").fill("94102");
 
    // Submit order
    await page.getByRole("button", { name: "Place order" }).click();
 
    // Verify confirmation
    await expect(page.getByText("Order confirmed")).toBeVisible();
    await expect(page.getByText(/order #/i)).toBeVisible();
  });
});

Keep your E2E test suite small and focused. Ten well-chosen E2E tests that cover your critical paths are more valuable than a hundred tests that duplicate coverage already provided by your component tests.

Testing Accessibility

Accessibility testing should be integrated into your component tests, not treated as a separate concern. Use jest-axe (or its Vitest-compatible variant) to automatically check for common accessibility violations.

import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { LoginForm } from "./login-form";
 
expect.extend(toHaveNoViolations);
 
it("has no accessibility violations", async () => {
  const { container } = render(<LoginForm onSubmit={vi.fn()} />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Combine automated checks with manual testing using a screen reader for critical flows. Automated tools catch about 30-40% of accessibility issues. The rest require human judgment.

Need help building this?

Our team specializes in turning these ideas into production systems. Let's talk.