Testing Strategies for Modern Web Applications

Testing Strategies for Modern Web Applications

BySanjay Goraniya
3 min read
Share:

Testing Strategies for Modern Web Applications

Testing is not optional—it's essential. But testing everything is impossible, and testing nothing is dangerous. The key is finding the right balance. After years of building test suites for production applications, I've learned what works and what doesn't.

The Testing Pyramid

The classic testing pyramid still holds:

Code
        /\
       /E2E\        ← Few, slow, expensive
      /------\
     /Integration\  ← Some, medium speed
    /------------\
   /   Unit Tests  \ ← Many, fast, cheap
  /----------------\

Unit Tests (70%)

Fast, isolated tests of individual functions/components.

Integration Tests (20%)

Test how components work together.

E2E Tests (10%)

Test complete user flows.

Unit Testing

What to Test

  • Business logic - Core functionality
  • Edge cases - Boundary conditions
  • Error handling - Failure scenarios
  • Transformations - Data processing

What NOT to Test

  • Framework code - Don't test React, Express, etc.
  • Third-party libraries - They're already tested
  • Implementation details - Test behavior, not how

Example: Unit Test

Code
// Function to test
function calculateTotal(items, discount = 0) {
  if (!Array.isArray(items)) {
    throw new Error('Items must be an array');
  }
  
  const subtotal = items.reduce((sum, item) => {
    if (item.price < 0) {
      throw new Error('Item price cannot be negative');
    }
    return sum + item.price * item.quantity;
  }, 0);
  
  return subtotal * (1 - discount);
}

// Tests
describe('calculateTotal', () => {
  it('should calculate total for valid items', () => {
    const items = [
      { price: 10, quantity: 2 },
      { price: 5, quantity: 3 }
    ];
    expect(calculateTotal(items)).toBe(35);
  });

  it('should apply discount', () => {
    const items = [{ price: 100, quantity: 1 }];
    expect(calculateTotal(items, 0.1)).toBe(90);
  });

  it('should throw error for invalid input', () => {
    expect(() => calculateTotal(null)).toThrow('Items must be an array');
  });

  it('should throw error for negative price', () => {
    const items = [{ price: -10, quantity: 1 }];
    expect(() => calculateTotal(items)).toThrow('Item price cannot be negative');
  });
});

Integration Testing

What to Test

  • API endpoints - Request/response flow
  • Database operations - CRUD operations
  • Service interactions - Multiple services working together

Example: API Integration Test

Code
describe('POST /api/users', () => {
  beforeEach(async () => {
    await db.clear();
  });

  it('should create a new user', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({
        name: 'John Doe',
        email: 'john@example.com'
      })
      .expect(201);

    expect(response.body).toMatchObject({
      id: expect.any(String),
      name: 'John Doe',
      email: 'john@example.com'
    });

    // Verify in database
    const user = await db.query('SELECT * FROM users WHERE id = $1', [response.body.id]);
    expect(user.rows[0].name).toBe('John Doe');
  });

  it('should return 400 for invalid email', async () => {
    await request(app)
      .post('/api/users')
      .send({
        name: 'John Doe',
        email: 'invalid-email'
      })
      .expect(400);
  });
});

E2E Testing

What to Test

  • Critical user flows - Login, checkout, etc.
  • Happy paths - Main user journeys
  • Cross-browser - Different browsers

Example: E2E Test

Code
describe('User Registration Flow', () => {
  it('should allow user to register and login', async () => {
    // Register
    await page.goto('http://localhost:3000/register');
    await page.fill('#name', 'John Doe');
    await page.fill('#email', 'john@example.com');
    await page.fill('#password', 'password123');
    await page.click('button[type="submit"]');

    // Should redirect to login
    await page.waitForURL('**/login');
    
    // Login
    await page.fill('#email', 'john@example.com');
    await page.fill('#password', 'password123');
    await page.click('button[type="submit"]');

    // Should be logged in
    await page.waitForURL('**/dashboard');
    expect(await page.textContent('h1')).toBe('Dashboard');
  });
});

Test Organization

Structure

Code
tests/
  unit/
    services/
      userService.test.js
    utils/
      helpers.test.js
  integration/
    api/
      users.test.js
      orders.test.js
  e2e/
    flows/
      registration.test.js
      checkout.test.js

Test Data Management

Use Factories

Code
// factories/userFactory.js
function createUser(overrides = {}) {
  return {
    id: faker.uuid(),
    name: faker.name.fullName(),
    email: faker.internet.email(),
    ...overrides
  };
}

// In tests
const user = createUser({ name: 'John Doe' });

Use Fixtures

Code
// fixtures/users.js
export const adminUser = {
  id: '1',
  name: 'Admin',
  email: 'admin@example.com',
  role: 'admin'
};

export const regularUser = {
  id: '2',
  name: 'User',
  email: 'user@example.com',
  role: 'user'
};

Mocking and Stubbing

When to Mock

  • External APIs - Don't hit real APIs in tests
  • Database - Use test database or mocks
  • Time - Mock dates for time-sensitive tests
  • Random values - Mock for deterministic tests

Example: Mocking External API

Code
// Mock external API
jest.mock('../services/paymentService', () => ({
  chargeCard: jest.fn()
}));

test('should process payment', async () => {
  const { chargeCard } = require('../services/paymentService');
  chargeCard.mockResolvedValue({ success: true, transactionId: '123' });

  const result = await processOrder(order);
  
  expect(chargeCard).toHaveBeenCalledWith(order.payment);
  expect(result.success).toBe(true);
});

Test Coverage

Aim for Meaningful Coverage

  • 80%+ coverage is a good target
  • 100% coverage is often not worth it
  • Focus on critical paths - Business logic, error handling

Coverage Tools

Code
// jest.config.js
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/**/*.test.{js,jsx}',
    '!src/index.js'
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

Testing Best Practices

1. Write Tests First (TDD)

Code
// Write test first
test('should calculate discount', () => {
  expect(calculateDiscount(100, 0.1)).toBe(90);
});

// Then implement
function calculateDiscount(price, discount) {
  return price * (1 - discount);
}

2. Test Behavior, Not Implementation

Code
// Bad: Tests implementation
test('should call setState', () => {
  const setState = jest.fn();
  component.setState = setState;
  component.handleClick();
  expect(setState).toHaveBeenCalled();
});

// Good: Tests behavior
test('should update count when clicked', () => {
  render(<Counter />);
  fireEvent.click(screen.getByText('Increment'));
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

3. Keep Tests Independent

Code
// Bad: Tests depend on each other
let counter = 0;

test('should increment', () => {
  counter++;
  expect(counter).toBe(1);
});

test('should increment again', () => {
  counter++; // Depends on previous test
  expect(counter).toBe(2);
});

// Good: Tests are independent
test('should increment', () => {
  const counter = new Counter();
  counter.increment();
  expect(counter.value).toBe(1);
});

test('should increment again', () => {
  const counter = new Counter();
  counter.increment();
  counter.increment();
  expect(counter.value).toBe(2);
});

4. Use Descriptive Test Names

Code
// Bad
test('test1', () => { ... });
test('works', () => { ... });

// Good
test('should return error when email is invalid', () => { ... });
test('should create user with valid data', () => { ... });

Continuous Testing

Run Tests in CI/CD

Code
# .github/workflows/test.yml
test:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
    - run: npm ci
    - run: npm test
    - run: npm run test:coverage

Real-World Example

Challenge: Large codebase with low test coverage, frequent bugs in production.

Strategy:

  1. Started with critical paths - Payment, authentication
  2. Added unit tests - Business logic functions
  3. Added integration tests - API endpoints
  4. Added E2E tests - Critical user flows
  5. Set coverage thresholds - 80% for new code

Result:

  • Coverage: 20% → 85%
  • Production bugs: 10/month → 1/month
  • Deployment confidence: High

Best Practices Summary

  1. Test behavior, not implementation - Focus on what, not how
  2. Keep tests fast - Slow tests don't get run
  3. Write readable tests - Tests are documentation
  4. Test edge cases - Boundary conditions matter
  5. Mock external dependencies - Isolate your code
  6. Maintain test data - Use factories and fixtures
  7. Run tests in CI - Catch issues early
  8. Review test failures - They're learning opportunities

Conclusion

Good testing is about confidence—confidence that your code works, confidence to deploy, confidence to refactor. The testing pyramid provides a framework, but the most important thing is to:

  • Start testing - Even imperfect tests are better than none
  • Test what matters - Focus on critical paths
  • Iterate - Tests improve over time
  • Maintain - Keep tests up to date

Remember: Tests are not a burden—they're an investment in code quality and team confidence.

What testing challenges have you faced? What strategies have worked best for your team?

Share:

Related Posts