Quality Assurance Testing QA TDD Tutorial

Testing Strategies for Modern Web Applications

Ravali Ravali April 15, 2025 7 min read
Testing Strategies for Modern Web Applications

I’ve seen teams burn out on testing and teams that ship bugs because they don’t test enough. The difference isn’t effort - it’s strategy. After building test suites for applications used by millions, I’ve learned that the testing pyramid isn’t a rigid rule - it’s a guideline that needs tailoring to your context.

My Experience

We launched a critical financial application without enough testing. The bug that slipped through - a rounding error in currency calculations - cost us $40,000 in reconciliation and three sleepless weekends.

That experience changed everything. We rebuilt our test strategy from scratch: unit tests for logic, integration tests for flows, E2E for critical paths. Six months later, our bug rate dropped significantly.

Here’s how to build a testing strategy that actually works.

The Testing Pyramid

The testing pyramid represents the ideal distribution of test types:

  • E2E Tests (Top): Few, slow, expensive - Run rarely: pre-commit, CI
  • Integration Tests (Middle): Medium - API flows, component interaction
  • Unit Tests (Base): Many, fast, cheap

When to Use Each Layer

LayerPurposeSpeedExamples
UnitLogic, utilitiesUnder 100msMath functions, helpers
IntegrationComponent interactionUnder 500msAPI flows, user actions
E2ECritical user journeysOver 30sCheckout, login

Unit Testing: The Foundation

What Makes a Good Unit Test

// Test ONE thing
describe('calculateDiscount', () => {
  it('applies percentage discount correctly', () => {
    const result = calculateDiscount(100, 20);
    expect(result).toBe(80);
  });

  it('returns 0 when discount is 100%', () => {
    const result = calculateDiscount(100, 100);
    expect(result).toBe(0);
  });

  it('throws error for negative prices', () => {
    expect(() => calculateDiscount(-10, 20)).toThrow();
  });
});

Avoiding Test Coupling

// Bad: Tests depend on each other
let user: User;

beforeAll(async () => {
  user = await createUser();
});

it('creates a user', () => {
  expect(user.id).toBeDefined();
});

it('updates the user', async () => {
  user.name = 'Updated';
  await updateUser(user);
  const fresh = await getUser(user.id);
  expect(fresh.name).toBe('Updated'); // Depends on previous test!
});

// Good: Each test is independent
it('creates a user', async () => {
  const user = await createUser();
  expect(user.id).toBeDefined();
});

it('updates a user', async () => {
  const user = await createUser();
  user.name = 'Updated';
  await updateUser(user);
  const fresh = await getUser(user.id);
  expect(fresh.name).toBe('Updated');
});

Integration Testing

Testing React Components

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { CartProvider } from './CartContext';
import { AddToCartButton } from './AddToCartButton';

function renderWithContext(ui: React.ReactElement) {
  return render(
    <CartProvider>
      {ui}
    </CartProvider>
  );
}

it('adds item to cart when clicked', async () => {
  renderWithContext(<AddToCartButton productId="123" />);

  const button = screen.getByRole('button', { name: /add to cart/i });
  fireEvent.click(button);

  await waitFor(() => {
    expect(screen.getByText(/items: 1/i)).toBeInTheDocument();
  });
});

it('shows loading state while adding', () => {
  renderWithContext(<AddToCartButton productId="123" />);

  const button = screen.getByRole('button', { name: /add to cart/i });
  fireEvent.click(button);

  expect(button).toBeDisabled();
  expect(button).toHaveTextContent(/adding\.\.\./i);
});

API Integration Testing

describe('POST /api/users', () => {
  it('creates a new user', async () => {
    const newUser = {
      name: 'Test User',
      email: 'test@example.com',
    };

    const response = await request(app)
      .post('/api/users')
      .send(newUser)
      .expect(201);

    expect(response.body).toMatchObject({
      id: expect.any(String),
      name: newUser.name,
      email: newUser.email,
    });
  });

  it('validates email format', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Test', email: 'invalid' })
      .expect(400);

    expect(response.body.error).toContain('email');
  });
});

E2E Testing with Playwright

Critical Path Testing

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

test('complete checkout flow', async ({ page }) => {
  // Setup: Add items to cart via API
  await page.route('/api/cart', async route => {
    await route.fulfill({ body: { items: [{ id: '1', quantity: 2 }] } });
  });

  // Navigate to cart
  await page.goto('/cart');
  await expect(page.locator('.cart-items')).toHaveCount(2);

  // Proceed to checkout
  await page.click('[data-testid="checkout-button"]');

  // Fill shipping info
  await page.fill('[data-testid="shipping-name"]', 'John Doe');
  await page.fill('[data-testid="shipping-address"]', '123 Main St');
  await page.click('[data-testid="continue-payment"]');

  // Complete payment (use test card)
  await page.fill('[data-testid="card-number"]', '4242424242424242');
  await page.fill('[data-testid="expiry"]', '12/26');
  await page.fill('[data-testid="cvv"]', '123');
  await page.click('[data-testid="place-order"]');

  // Verify success
  await expect(page.locator('.order-success')).toBeVisible();
  await expect(page.locator('.order-number')).toContainText('ORD-');
});

E2E Test Structure

tests/
├── e2e/
│   ├── critical/
│   │   ├── checkout.spec.ts
│   │   ├── login.spec.ts
│   │   └── payment.spec.ts
│   ├── smoke/
│   │   └── navigation.spec.ts
│   └── regression/
│       ├── forms.spec.ts
│       └── filters.spec.ts

Test Coverage: What’s Enough?

Component TypeCoverage TargetRationale
Utilities90%+Pure functions, easy to test
Business Logic80%+Critical paths
UI Components70%+Integration tests cover most
E2ECritical flows onlyExpensive to maintain

Focus on Behavior, Not Implementation

// Test behavior, not implementation
describe('ShoppingCart', () => {
  // Good: Tests what the user sees
  it('displays total price with tax', () => {
    render(<Cart items={[{ price: 100 }]} taxRate={0.1} />);
    expect(screen.getByText('$110.00')).toBeInTheDocument();
  });

  // Bad: Tests implementation details
  it('calls calculateTax with correct rate', () => {
    const calculateTax = jest.spyOn(utils, 'calculateTax');
    render(<Cart items={[{ price: 100 }]} taxRate={0.1} />);
    expect(calculateTax).toHaveBeenCalledWith(100, 0.1);
  });
});

What Students Should Do Next

  1. Start with unit tests for utility functions — They’re easiest to test and provide quick wins
  2. Add integration tests for API endpoints — These catch the most bugs per line of code
  3. Write E2E tests for your most critical user flows — Protect your business-critical paths

Testing isn’t about achieving 100% coverage—it’s about building confidence in the code you ship.

Share this article

Ravali

Ravali

Software Engineer & Content Creator

Ravali writes practical engineering guides for students and developers, combining hands-on project stories, career lessons, and trend-focused technical research.