Software Testing Strategies: Complete System from Unit to E2E Testing

Master testing pyramid, unit testing, integration testing, E2E testing and TDD practices

Software Testing Strategies: Complete System from Unit to E2E Testing

Testing is the cornerstone of software quality. This article explores testing strategies, best practices, and tooling.

Testing Pyramid

Test Layers

Testing Pyramid:
┌─────────────────────────────────────────────────────┐
│                                                     │
│                    /\                               │
│                   /  \    E2E Tests                │
│                  /    \   (Few, verify key flows) │
│                 /──────\                            │
│                /        \  Integration Tests        │
│               /          \ (Moderate, verify       │
│              /────────────\ module collaboration)  │
│             /              \ Unit Tests             │
│            /                \(Many, verify single │
│           /──────────────────\ functions)          │
│                                                     │
│   Characteristics:                                  │
│   ├── Bottom: Fast, cheap, many                   │
│   ├── Middle: Moderate speed and cost             │
│   └── Top: Slow, expensive, few                   │
│                                                     │
└─────────────────────────────────────────────────────┘

Test Type Comparison

TypeSpeedIsolationCoverageMaintenance
UnitMillisecondsHighSingle functionLow
IntegrationSecondsMediumMultiple modulesMedium
E2EMinutesLowFull systemHigh

Unit Testing

Basic Examples

// user.service.ts
export class UserService {
  constructor(private userRepository: UserRepository) {}

  async createUser(data: CreateUserDto): Promise<User> {
    if (!data.email.includes('@')) {
      throw new ValidationError('Invalid email format');
    }

    const existingUser = await this.userRepository.findByEmail(data.email);
    if (existingUser) {
      throw new ConflictError('Email already exists');
    }

    const passwordHash = await hashPassword(data.password);
    return this.userRepository.create({
      ...data,
      password: passwordHash,
    });
  }
}

// user.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

describe('UserService', () => {
  let userService: UserService;
  let mockRepository: jest.Mocked<UserRepository>;

  beforeEach(() => {
    mockRepository = {
      findByEmail: vi.fn(),
      create: vi.fn(),
    };
    userService = new UserService(mockRepository);
  });

  describe('createUser', () => {
    it('should create user with valid data', async () => {
      const userData = {
        email: 'test@example.com',
        password: 'password123',
        name: 'Test User',
      };

      mockRepository.findByEmail.mockResolvedValue(null);
      mockRepository.create.mockResolvedValue({
        id: '1',
        ...userData,
        password: 'hashed',
      });

      const result = await userService.createUser(userData);

      expect(result.email).toBe(userData.email);
      expect(mockRepository.create).toHaveBeenCalledWith(
        expect.objectContaining({
          email: userData.email,
          name: userData.name,
        })
      );
    });

    it('should throw error for invalid email', async () => {
      const userData = {
        email: 'invalid-email',
        password: 'password123',
        name: 'Test',
      };

      await expect(userService.createUser(userData))
        .rejects.toThrow(ValidationError);
    });

    it('should throw error for duplicate email', async () => {
      mockRepository.findByEmail.mockResolvedValue({ id: '1' } as User);

      await expect(userService.createUser({
        email: 'existing@example.com',
        password: 'password',
        name: 'Test',
      })).rejects.toThrow(ConflictError);
    });
  });
});

Mocks and Stubs

// Mocking external dependencies
import { vi } from 'vitest';

// Mock modules
vi.mock('./database', () => ({
  query: vi.fn(),
}));

// Mock functions
const mockFn = vi.fn()
  .mockReturnValueOnce('first')
  .mockReturnValueOnce('second')
  .mockReturnValue('default');

// Mock time
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01'));

// Restore after tests
afterEach(() => {
  vi.restoreAllMocks();
});

// Spy on real functions
const spy = vi.spyOn(console, 'log');
someFunction();
expect(spy).toHaveBeenCalledWith('expected message');

Testing Async Code

describe('Async Operations', () => {
  it('should handle promises', async () => {
    const result = await asyncFunction();
    expect(result).toBe('expected');
  });

  it('should handle rejected promises', async () => {
    await expect(failingAsyncFunction())
      .rejects.toThrow('Error message');
  });

  it('should handle callbacks', (done) => {
    callbackFunction((error, result) => {
      expect(error).toBeNull();
      expect(result).toBe('expected');
      done();
    });
  });

  it('should handle timers', () => {
    vi.useFakeTimers();

    const callback = vi.fn();
    setTimeout(callback, 1000);

    vi.advanceTimersByTime(1000);

    expect(callback).toHaveBeenCalled();
  });
});

Integration Testing

API Testing

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { setupTestDatabase, teardownTestDatabase } from './helpers';

describe('User API', () => {
  beforeAll(async () => {
    await setupTestDatabase();
  });

  afterAll(async () => {
    await teardownTestDatabase();
  });

  describe('POST /api/users', () => {
    it('should create a new user', async () => {
      const response = await request(app)
        .post('/api/users')
        .send({
          email: 'new@example.com',
          password: 'password123',
          name: 'New User',
        })
        .expect(201);

      expect(response.body).toMatchObject({
        email: 'new@example.com',
        name: 'New User',
      });
      expect(response.body.password).toBeUndefined();
    });

    it('should return 422 for invalid data', async () => {
      const response = await request(app)
        .post('/api/users')
        .send({
          email: 'invalid',
          password: '123',
        })
        .expect(422);

      expect(response.body.error.code).toBe('VALIDATION_ERROR');
    });
  });

  describe('GET /api/users/:id', () => {
    it('should return user by id', async () => {
      const response = await request(app)
        .get('/api/users/1')
        .set('Authorization', 'Bearer valid-token')
        .expect(200);

      expect(response.body.id).toBe('1');
    });

    it('should return 401 without auth', async () => {
      await request(app)
        .get('/api/users/1')
        .expect(401);
    });
  });
});

Database Testing

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

describe('User Repository', () => {
  beforeEach(async () => {
    // Clean test data
    await prisma.user.deleteMany();
  });

  afterEach(async () => {
    await prisma.$disconnect();
  });

  it('should create and retrieve user', async () => {
    const created = await prisma.user.create({
      data: {
        email: 'test@example.com',
        name: 'Test User',
        passwordHash: 'hash',
      },
    });

    const found = await prisma.user.findUnique({
      where: { id: created.id },
    });

    expect(found?.email).toBe('test@example.com');
  });

  it('should enforce unique email constraint', async () => {
    await prisma.user.create({
      data: {
        email: 'unique@example.com',
        name: 'First',
        passwordHash: 'hash',
      },
    });

    await expect(
      prisma.user.create({
        data: {
          email: 'unique@example.com',
          name: 'Second',
          passwordHash: 'hash',
        },
      })
    ).rejects.toThrow();
  });
});

E2E Testing

Playwright Testing

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

test.describe('User Registration Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/register');
  });

  test('should register new user successfully', async ({ page }) => {
    // Fill form
    await page.fill('[name="email"]', 'newuser@example.com');
    await page.fill('[name="password"]', 'SecurePass123!');
    await page.fill('[name="confirmPassword"]', 'SecurePass123!');
    await page.fill('[name="name"]', 'New User');

    // Submit form
    await page.click('button[type="submit"]');

    // Verify redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Welcome, New User');
  });

  test('should show validation errors', async ({ page }) => {
    await page.fill('[name="email"]', 'invalid-email');
    await page.click('button[type="submit"]');

    await expect(page.locator('.error-message'))
      .toContainText('Please enter a valid email');
  });

  test('should handle network errors gracefully', async ({ page }) => {
    // Mock network error
    await page.route('/api/register', route => {
      route.abort('failed');
    });

    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    await expect(page.locator('.toast-error'))
      .toContainText('Network error');
  });
});

test.describe('Shopping Cart', () => {
  test('should add items and checkout', async ({ page }) => {
    // Login
    await page.goto('/login');
    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="password"]', 'password');
    await page.click('button[type="submit"]');

    // Add products
    await page.goto('/products');
    await page.click('[data-product-id="1"] .add-to-cart');
    await page.click('[data-product-id="2"] .add-to-cart');

    // View cart
    await page.click('.cart-icon');
    await expect(page.locator('.cart-count')).toHaveText('2');

    // Checkout
    await page.click('.checkout-button');
    await page.fill('[name="cardNumber"]', '4242424242424242');
    await page.fill('[name="expiry"]', '12/25');
    await page.fill('[name="cvc"]', '123');
    await page.click('.pay-button');

    // Verify success
    await expect(page).toHaveURL('/order-confirmation');
    await expect(page.locator('.order-status')).toContainText('Success');
  });
});

Visual Regression Testing

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

test('visual regression - homepage', async ({ page }) => {
  await page.goto('/');

  // Wait for page to stabilize
  await page.waitForLoadState('networkidle');

  // Screenshot comparison
  await expect(page).toHaveScreenshot('homepage.png', {
    maxDiffPixels: 100,
  });
});

test('visual regression - component', async ({ page }) => {
  await page.goto('/components/button');

  const button = page.locator('.primary-button');
  await expect(button).toHaveScreenshot('primary-button.png');
});

TDD Practices

Red-Green-Refactor Cycle

// 1. Red: Write a failing test
test('should calculate order total with discount', () => {
  const order = new Order([
    { price: 100, quantity: 2 },
    { price: 50, quantity: 1 },
  ]);

  order.applyDiscount(0.1); // 10% discount

  expect(order.total).toBe(225); // (200 + 50) * 0.9
});

// 2. Green: Write minimum code to pass
class Order {
  private items: OrderItem[];
  private discountRate = 0;

  constructor(items: OrderItem[]) {
    this.items = items;
  }

  applyDiscount(rate: number): void {
    this.discountRate = rate;
  }

  get total(): number {
    const subtotal = this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    return subtotal * (1 - this.discountRate);
  }
}

// 3. Refactor: Improve code structure
class Order {
  private items: OrderItem[];
  private discountRate = 0;

  constructor(items: OrderItem[]) {
    this.items = items;
  }

  applyDiscount(rate: number): void {
    if (rate < 0 || rate > 1) {
      throw new Error('Discount rate must be between 0 and 1');
    }
    this.discountRate = rate;
  }

  get subtotal(): number {
    return this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }

  get discount(): number {
    return this.subtotal * this.discountRate;
  }

  get total(): number {
    return this.subtotal - this.discount;
  }
}

Test Coverage

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.d.ts',
        '**/*.config.*',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});

Best Practices Summary

Testing Best Practices:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Writing Principles                                │
│   ├── One test verifies one thing                 │
│   ├── Tests should run independently              │
│   ├── Test names clearly describe behavior        │
│   └── Follow AAA pattern (Arrange-Act-Assert)     │
│                                                     │
│   Testing Strategy                                  │
│   ├── Prioritize unit tests                       │
│   ├── Integration tests for key paths             │
│   ├── E2E tests for core flows                    │
│   └── Balance coverage and maintenance cost       │
│                                                     │
│   Test Maintenance                                  │
│   ├── Avoid testing implementation details        │
│   ├── Use test factories and helpers              │
│   ├── Regularly clean up outdated tests          │
│   └── Run tests in CI                             │
│                                                     │
└─────────────────────────────────────────────────────┘
ScenarioRecommended Approach
Pure functionsUnit tests
API endpointsIntegration tests
User flowsE2E tests
UI componentsComponent tests + snapshots
Complex logicTDD

Testing is not a burden but a quality guarantee. Invest in testing to make code more reliable and refactoring more confident.


Tests are the safety net of code. Write good tests to confidently change code.