Engineering12 min read1,528 words

Testing Strategies for Modern Applications: Unit, Integration, and E2E

Build confidence in your code with comprehensive testing strategies. Learn test pyramid principles, mocking patterns, and automation techniques.

MC

Michael Chen

Comprehensive testing is the foundation of reliable software. As applications grow in complexity, a well-structured testing strategy becomes essential for maintaining quality, preventing regressions, and shipping features with confidence. This guide covers modern testing strategies from unit tests to end-to-end testing, implementing the test pyramid, and building automation that accelerates development.

The Test Pyramid

The test pyramid, introduced by Mike Cohn, visualizes the ideal distribution of tests in a healthy codebase. The pyramid has three layers, with more tests at the bottom and fewer at the top:

  • Unit Tests (Base): 70% - Fast, isolated tests of individual functions/classes
  • Integration Tests (Middle): 20% - Test interactions between components
  • E2E Tests (Top): 10% - Test complete user workflows through the UI

This distribution balances speed, cost, and confidence. Unit tests run in milliseconds and catch most bugs. Integration tests verify components work together. E2E tests ensure critical user journeys function correctly but are slower and more brittle.

Unit Testing

Unit tests verify individual functions or classes in isolation. They should be fast, deterministic, and focused on a single behavior.

Example: Jest Unit Tests

javascript
// user.service.js
class UserService {
  constructor(database, emailService) {
    this.db = database;
    this.emailService = emailService;
  }
  
  async createUser(userData) {
    // Validate input
    if (!userData.email || !userData.name) {
      throw new Error('Email and name are required');
    }
    
    // Check if user exists
    const existing = await this.db.findByEmail(userData.email);
    if (existing) {
      throw new Error('User already exists');
    }
    
    // Create user
    const user = await this.db.create(userData);
    
    // Send welcome email
    await this.emailService.sendWelcome(user.email, user.name);
    
    return user;
  }
}

// user.service.test.js
describe('UserService', () => {
  let userService;
  let mockDb;
  let mockEmailService;
  
  beforeEach(() => {
    // Create mocks
    mockDb = {
      findByEmail: jest.fn(),
      create: jest.fn()
    };
    mockEmailService = {
      sendWelcome: jest.fn()
    };
    
    userService = new UserService(mockDb, mockEmailService);
  });
  
  describe('createUser', () => {
    it('should create a user with valid data', async () => {
      const userData = { email: 'test@example.com', name: 'Test User' };
      const createdUser = { id: '123', ...userData };
      
      mockDb.findByEmail.mockResolvedValue(null);
      mockDb.create.mockResolvedValue(createdUser);
      
      const result = await userService.createUser(userData);
      
      expect(result).toEqual(createdUser);
      expect(mockDb.findByEmail).toHaveBeenCalledWith('test@example.com');
      expect(mockDb.create).toHaveBeenCalledWith(userData);
      expect(mockEmailService.sendWelcome).toHaveBeenCalledWith(
        'test@example.com',
        'Test User'
      );
    });
    
    it('should throw error if email is missing', async () => {
      await expect(
        userService.createUser({ name: 'Test' })
      ).rejects.toThrow('Email and name are required');
    });
    
    it('should throw error if user already exists', async () => {
      mockDb.findByEmail.mockResolvedValue({ id: '123' });
      
      await expect(
        userService.createUser({ email: 'test@example.com', name: 'Test' })
      ).rejects.toThrow('User already exists');
    });
  });
});

Integration Testing

Integration tests verify that multiple components work together correctly. They test actual integrations with databases, APIs, and external services.

javascript
// user.integration.test.js
const request = require('supertest');
const app = require('../app');
const { setupTestDb, clearTestDb } = require('../test-utils');

describe('User API Integration Tests', () => {
  beforeAll(async () => {
    await setupTestDb();
  });
  
  afterEach(async () => {
    await clearTestDb();
  });
  
  describe('POST /users', () => {
    it('should create a user and return 201', async () => {
      const userData = {
        name: 'John Doe',
        email: 'john@example.com',
        password: 'SecurePass123!'
      };
      
      const response = await request(app)
        .post('/users')
        .send(userData)
        .expect(201);
      
      expect(response.body).toMatchObject({
        id: expect.any(String),
        name: 'John Doe',
        email: 'john@example.com'
      });
      expect(response.body.password).toBeUndefined();
      
      // Verify user in database
      const dbUser = await User.findById(response.body.id);
      expect(dbUser).toBeTruthy();
      expect(dbUser.email).toBe('john@example.com');
    });
    
    it('should return 400 for invalid email', async () => {
      const response = await request(app)
        .post('/users')
        .send({
          name: 'John Doe',
          email: 'invalid-email',
          password: 'SecurePass123!'
        })
        .expect(400);
      
      expect(response.body.error).toBeTruthy();
      expect(response.body.error.details).toContainEqual(
        expect.objectContaining({
          field: 'email',
          message: expect.stringContaining('valid email')
        })
      );
    });
    
    it('should return 409 for duplicate email', async () => {
      const userData = {
        name: 'John Doe',
        email: 'john@example.com',
        password: 'SecurePass123!'
      };
      
      // Create first user
      await request(app).post('/users').send(userData).expect(201);
      
      // Attempt to create duplicate
      const response = await request(app)
        .post('/users')
        .send(userData)
        .expect(409);
      
      expect(response.body.error.message).toContain('already exists');
    });
  });
  
  describe('GET /users/:id', () => {
    it('should return user by ID', async () => {
      const user = await User.create({
        name: 'Jane Doe',
        email: 'jane@example.com',
        password: 'hashed_password'
      });
      
      const response = await request(app)
        .get(`/users/${user.id}`)
        .expect(200);
      
      expect(response.body).toMatchObject({
        id: user.id,
        name: 'Jane Doe',
        email: 'jane@example.com'
      });
    });
    
    it('should return 404 for non-existent user', async () => {
      await request(app)
        .get('/users/nonexistent-id')
        .expect(404);
    });
  });
});

End-to-End Testing

E2E tests simulate real user interactions through the entire application stack. Tools like Cypress and Playwright automate browser interactions.

javascript
// cypress/e2e/user-registration.cy.js
describe('User Registration Flow', () => {
  beforeEach(() => {
    cy.visit('/signup');
  });
  
  it('should successfully register a new user', () => {
    // Fill out registration form
    cy.get('[data-testid="name-input"]').type('John Doe');
    cy.get('[data-testid="email-input"]').type('john@example.com');
    cy.get('[data-testid="password-input"]').type('SecurePass123!');
    cy.get('[data-testid="confirm-password-input"]').type('SecurePass123!');
    
    // Submit form
    cy.get('[data-testid="signup-button"]').click();
    
    // Verify success
    cy.url().should('include', '/dashboard');
    cy.get('[data-testid="welcome-message"]')
      .should('be.visible')
      .and('contain', 'Welcome, John');
    
    // Verify user can navigate
    cy.get('[data-testid="profile-link"]').click();
    cy.get('[data-testid="user-email"]').should('contain', 'john@example.com');
  });
  
  it('should show validation errors for invalid input', () => {
    cy.get('[data-testid="email-input"]').type('invalid-email');
    cy.get('[data-testid="password-input"]').type('weak');
    cy.get('[data-testid="signup-button"]').click();
    
    cy.get('[data-testid="email-error"]')
      .should('be.visible')
      .and('contain', 'valid email');
    
    cy.get('[data-testid="password-error"]')
      .should('be.visible')
      .and('contain', 'at least 8 characters');
  });
  
  it('should handle server errors gracefully', () => {
    // Intercept API call and force error
    cy.intercept('POST', '/api/users', {
      statusCode: 500,
      body: { error: 'Internal server error' }
    });
    
    cy.get('[data-testid="email-input"]').type('john@example.com');
    cy.get('[data-testid="password-input"]').type('SecurePass123!');
    cy.get('[data-testid="signup-button"]').click();
    
    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', 'Something went wrong');
  });
});

Test-Driven Development (TDD)

TDD follows the Red-Green-Refactor cycle: write a failing test, make it pass, then refactor.

javascript
// 1. RED - Write failing test first
describe('calculateTotal', () => {
  it('should calculate total with tax', () => {
    const items = [
      { price: 10, quantity: 2 },
      { price: 5, quantity: 3 }
    ];
    
    const result = calculateTotal(items, 0.1); // 10% tax
    
    expect(result).toEqual({
      subtotal: 35,
      tax: 3.5,
      total: 38.5
    });
  });
});

// Test fails - function doesn't exist yet

// 2. GREEN - Write minimal code to pass
function calculateTotal(items, taxRate) {
  const subtotal = items.reduce((sum, item) => {
    return sum + (item.price * item.quantity);
  }, 0);
  
  const tax = subtotal * taxRate;
  const total = subtotal + tax;
  
  return { subtotal, tax, total };
}

// Test passes

// 3. REFACTOR - Improve code while keeping tests green
function calculateTotal(items, taxRate) {
  const subtotal = items.reduce(
    (sum, { price, quantity }) => sum + price * quantity,
    0
  );
  
  const tax = roundToCents(subtotal * taxRate);
  const total = subtotal + tax;
  
  return { subtotal, tax, total };
}

function roundToCents(amount) {
  return Math.round(amount * 100) / 100;
}

// Tests still pass with improved implementation

Mocking and Test Doubles

Test doubles isolate code under test from dependencies:

  • Mock: Objects with pre-programmed behavior and expectations
  • Stub: Returns canned responses to calls
  • Spy: Records information about how it was called
  • Fake: Working implementation with shortcuts (in-memory database)
javascript
// Mocking external API
const axios = require('axios');
jest.mock('axios');

describe('WeatherService', () => {
  it('should fetch weather data', async () => {
    const mockWeather = {
      temperature: 72,
      condition: 'Sunny',
      humidity: 45
    };
    
    axios.get.mockResolvedValue({ data: mockWeather });
    
    const weather = await weatherService.getWeather('New York');
    
    expect(axios.get).toHaveBeenCalledWith(
      expect.stringContaining('api.weather.com'),
      expect.objectContaining({
        params: { city: 'New York' }
      })
    );
    expect(weather).toEqual(mockWeather);
  });
});

// Spy to verify behavior
const loggerSpy = jest.spyOn(console, 'log');

await processData();

expect(loggerSpy).toHaveBeenCalledWith(
  expect.stringContaining('Processing complete')
);

loggerSpy.mockRestore();

Test Coverage and Quality Metrics

Track test coverage but don't obsess over 100%. Focus on testing critical paths and edge cases.

json
// package.json - Jest configuration
{
  "jest": {
    "collectCoverage": true,
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/tests/",
      "/coverage/"
    ]
  }
}
  • Line coverage: Percentage of code lines executed
  • Branch coverage: Percentage of decision branches taken
  • Function coverage: Percentage of functions called
  • Statement coverage: Percentage of statements executed

Continuous Integration Testing

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

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run linter
        run: npm run lint
      
      - name: Run unit tests
        run: npm run test:unit
      
      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/test
      
      - name: Run E2E tests
        run: npm run test:e2e
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: true

Best Practices

  • Test behavior, not implementation: Tests should verify what code does, not how
  • Keep tests independent: Each test should run in isolation
  • Use descriptive test names: "should return 404 when user not found"
  • Follow AAA pattern: Arrange, Act, Assert
  • Don't test external libraries: Trust they work, test your usage
  • Use factories for test data: Create consistent, reusable test fixtures
  • Test edge cases: Null, empty, boundary values, errors
  • Keep tests fast: Slow tests won't be run frequently
  • Test critical paths thoroughly: Authentication, payments, data loss scenarios
  • Review test failures carefully: Flaky tests undermine confidence

Conclusion

A comprehensive testing strategy is essential for building reliable, maintainable software. By following the test pyramid, writing unit tests for individual components, integration tests for interactions, and E2E tests for critical workflows, you create a safety net that enables confident refactoring and rapid iteration. Invest in good tests early—they pay dividends throughout the application lifecycle.

Testing Checklist

✓ Follow test pyramid distribution (70% unit, 20% integration, 10% E2E)

✓ Write tests before or alongside code (TDD)

✓ Maintain 80%+ code coverage on critical paths

✓ Use mocks to isolate units under test

✓ Keep tests fast and independent

✓ Run tests in CI/CD pipeline

✓ Test edge cases and error conditions

✓ Use descriptive test names

✓ Refactor tests as you refactor code

✓ Monitor and fix flaky tests immediately

Contact Jishu Labs to improve your testing strategy or build comprehensive test automation for your applications.

MC

About Michael Chen

Michael Chen is a Lead Solutions Architect at Jishu Labs with 12+ years of experience in software quality and testing. He has built testing frameworks for applications serving millions of users and has championed test-driven development practices across multiple organizations. Michael is passionate about code quality and developer productivity.

Related Articles

Ready to Build Your Next Project?

Let's discuss how our expert team can help bring your vision to life.

Top-Rated
Software Development
Company

Ready to Get Started?

Get consistent results. Collaborate in real-time.
Build Intelligent Apps. Work with Jishu Labs.

SCHEDULE MY CALL