Testing Strategies: Unit, Integration, and E2E Testing

A comprehensive testing strategy is essential for building reliable, maintainable software. Understanding the testing pyramid and implementing appropriate testing at each level ensures code quality and prevents regressions.

The Testing Pyramid

Unit Tests (Bottom Layer - 70%)

Test individual functions and methods in isolation:

// math.ts
export function add(a: number, b: number): number {
  return a + b;
}

export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error("Division by zero");
  }
  return a / b;
}
// math.test.ts
import { describe, expect, it } from "vitest";
import { add, divide } from "./math";

describe("Math utilities", () => {
  describe("add", () => {
    it("should add two positive numbers", () => {
      expect(add(2, 3)).toBe(5);
    });

    it("should add negative numbers", () => {
      expect(add(-2, -3)).toBe(-5);
    });

    it("should handle zero", () => {
      expect(add(0, 5)).toBe(5);
    });
  });

  describe("divide", () => {
    it("should divide two numbers", () => {
      expect(divide(10, 2)).toBe(5);
    });

    it("should throw error when dividing by zero", () => {
      expect(() => divide(10, 0)).toThrow("Division by zero");
    });
  });
});

Integration Tests (Middle Layer - 20%)

Test interactions between components:

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

  async createUser(userData: CreateUserInput): Promise<User> {
    // Validate input
    this.validateUserData(userData);

    // Check if user exists
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new Error("User already exists");
    }

    // Create user
    const user = await this.userRepository.create(userData);
    return user;
  }

  private validateUserData(data: CreateUserInput) {
    if (!data.email || !data.email.includes("@")) {
      throw new Error("Invalid email");
    }
    if (!data.name || data.name.length < 2) {
      throw new Error("Name too short");
    }
  }
}
// userService.integration.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { UserService } from "./userService";
import { InMemoryUserRepository } from "./repositories/inMemoryUserRepository";

describe("UserService Integration", () => {
  let userService: UserService;
  let userRepository: InMemoryUserRepository;

  beforeEach(() => {
    userRepository = new InMemoryUserRepository();
    userService = new UserService(userRepository);
  });

  describe("createUser", () => {
    it("should create a new user", async () => {
      const userData = {
        email: "john@example.com",
        name: "John Doe",
      };

      const user = await userService.createUser(userData);

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

    it("should throw error for duplicate email", async () => {
      const userData = {
        email: "john@example.com",
        name: "John Doe",
      };

      await userService.createUser(userData);

      await expect(userService.createUser(userData))
        .rejects.toThrow("User already exists");
    });

    it("should validate email format", async () => {
      const userData = {
        email: "invalid-email",
        name: "John Doe",
      };

      await expect(userService.createUser(userData))
        .rejects.toThrow("Invalid email");
    });
  });
});

End-to-End Tests (Top Layer - 10%)

Test complete user workflows:

// e2e/user-registration.test.ts
import { expect, test } from "@playwright/test";

test.describe("User Registration", () => {
  test("should allow user to register and login", async ({ page }) => {
    // Navigate to registration page
    await page.goto("/register");

    // Fill registration form
    await page.fill('[data-testid="email"]', "john@example.com");
    await page.fill('[data-testid="name"]', "John Doe");
    await page.fill('[data-testid="password"]', "password123");
    await page.fill('[data-testid="confirm-password"]', "password123");

    // Submit form
    await page.click('[data-testid="register-button"]');

    // Should redirect to login page
    await expect(page).toHaveURL("/login");

    // Login with new credentials
    await page.fill('[data-testid="email"]', "john@example.com");
    await page.fill('[data-testid="password"]', "password123");
    await page.click('[data-testid="login-button"]');

    // Should redirect to dashboard
    await expect(page).toHaveURL("/dashboard");

    // Should show welcome message
    await expect(page.locator('[data-testid="welcome-message"]'))
      .toContainText("Welcome, John Doe");
  });

  test("should show validation errors for invalid data", async ({ page }) => {
    await page.goto("/register");

    // Try to submit empty form
    await page.click('[data-testid="register-button"]');

    // Should show validation errors
    await expect(page.locator('[data-testid="email-error"]'))
      .toContainText("Email is required");
    await expect(page.locator('[data-testid="name-error"]'))
      .toContainText("Name is required");
  });
});

Test-Driven Development (TDD)

Red-Green-Refactor Cycle

  1. Red: Write a failing test
  2. Green: Make the test pass with minimal code
  3. Refactor: Improve code while keeping tests passing
// Step 1: Red - Write failing test
describe("StringCalculator", () => {
  it("should return 0 for empty string", () => {
    const calculator = new StringCalculator();
    expect(calculator.add("")).toBe(0);
  });
});

// Step 2: Green - Make test pass
class StringCalculator {
  add(numbers: string): number {
    if (numbers === "") {
      return 0;
    }
    // TODO: Implement full logic
    return 0;
  }
}

// Step 3: Refactor - Add more tests and improve implementation
describe("StringCalculator", () => {
  let calculator: StringCalculator;

  beforeEach(() => {
    calculator = new StringCalculator();
  });

  it("should return 0 for empty string", () => {
    expect(calculator.add("")).toBe(0);
  });

  it("should return number for single number", () => {
    expect(calculator.add("1")).toBe(1);
  });

  it("should return sum for two numbers", () => {
    expect(calculator.add("1,2")).toBe(3);
  });
});

class StringCalculator {
  add(numbers: string): number {
    if (numbers === "") {
      return 0;
    }

    const numberArray = numbers.split(",").map((n) => parseInt(n, 10));
    return numberArray.reduce((sum, num) => sum + num, 0);
  }
}

Mocking and Test Doubles

Stubs

Replace dependencies with simple implementations:

// emailService.ts
export interface EmailService {
  sendWelcomeEmail(email: string, name: string): Promise<void>;
}

// Stub implementation for testing
export class EmailServiceStub implements EmailService {
  sentEmails: Array<{ email: string; name: string }> = [];

  async sendWelcomeEmail(email: string, name: string): Promise<void> {
    this.sentEmails.push({ email, name });
  }
}

Mocks

Verify interactions with dependencies:

import { describe, expect, it, vi } from "vitest";

describe("UserService with mocks", () => {
  it("should send welcome email when user is created", async () => {
    const mockEmailService = {
      sendWelcomeEmail: vi.fn().mockResolvedValue(undefined),
    };

    const mockUserRepository = {
      create: vi.fn().mockResolvedValue({
        id: "123",
        email: "john@example.com",
        name: "John Doe",
      }),
    };

    const userService = new UserService(
      mockUserRepository,
      mockEmailService,
    );

    await userService.createUser({
      email: "john@example.com",
      name: "John Doe",
    });

    expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
      "john@example.com",
      "John Doe",
    );
  });
});

Spies

Observe method calls without changing behavior:

describe("UserService with spies", () => {
  it("should validate user data before creating", async () => {
    const userService = new UserService(userRepository, emailService);

    // Spy on the private validateUserData method
    const validateSpy = vi.spyOn(userService as any, "validateUserData");

    await userService.createUser({
      email: "john@example.com",
      name: "John Doe",
    });

    expect(validateSpy).toHaveBeenCalledWith({
      email: "john@example.com",
      name: "John Doe",
    });
  });
});

Testing React Components

Component Testing with Testing Library

// Button.tsx
interface ButtonProps {
  onClick: () => void;
  children: React.ReactNode;
  disabled?: boolean;
}

export const Button: React.FC<ButtonProps> = ({
  onClick,
  children,
  disabled = false,
}) => {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className="btn btn-primary"
    >
      {children}
    </button>
  );
};
// Button.test.tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./Button";

describe("Button", () => {
  it("should render children", () => {
    render(<Button onClick={() => {}}>Click me</Button>);

    expect(screen.getByRole("button", { name: /click me/i }))
      .toBeInTheDocument();
  });

  it("should call onClick when clicked", () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByRole("button", { name: /click me/i }));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it("should be disabled when disabled prop is true", () => {
    render(<Button onClick={() => {}} disabled>Click me</Button>);

    expect(screen.getByRole("button", { name: /click me/i })).toBeDisabled();
  });
});

Custom Render Function

// test-utils.tsx
import React, { ReactElement } from "react";
import { render, RenderOptions } from "@testing-library/react";
import { ThemeProvider } from "./theme";

const AllTheProviders: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  return (
    <ThemeProvider>
      {children}
    </ThemeProvider>
  );
};

const customRender = (
  ui: ReactElement,
  options?: Omit<RenderOptions, "wrapper">,
) => render(ui, { wrapper: AllTheProviders, ...options });

export * from "@testing-library/react";
export { customRender as render };

Testing Asynchronous Code

Promises

describe("AsyncService", () => {
  it("should resolve with correct data", async () => {
    const service = new AsyncService();
    const result = await service.fetchData();

    expect(result).toEqual({ data: "expected" });
  });

  it("should reject with error for invalid input", async () => {
    const service = new AsyncService();

    await expect(service.fetchData("invalid"))
      .rejects.toThrow("Invalid input");
  });
});

Async/Await in Tests

describe("UserAPI", () => {
  it("should handle network errors gracefully", async () => {
    // Mock fetch to simulate network error
    global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));

    const api = new UserAPI();

    await expect(api.getUsers()).rejects.toThrow("Network error");
  });

  it("should retry on failure", async () => {
    let callCount = 0;
    global.fetch = vi.fn()
      .mockImplementationOnce(() => {
        callCount++;
        throw new Error("Network error");
      })
      .mockImplementationOnce(() => {
        callCount++;
        return Promise.resolve({
          ok: true,
          json: () => Promise.resolve([{ id: 1, name: "John" }]),
        });
      });

    const api = new UserAPI();
    const users = await api.getUsers();

    expect(callCount).toBe(2);
    expect(users).toEqual([{ id: 1, name: "John" }]);
  });
});

Test Organization and Patterns

Page Object Pattern

// pages/LoginPage.ts
export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto("/login");
  }

  async login(email: string, password: string) {
    await this.page.fill('[data-testid="email"]', email);
    await this.page.fill('[data-testid="password"]', password);
    await this.page.click('[data-testid="login-button"]');
  }

  async getErrorMessage() {
    return this.page.textContent('[data-testid="error-message"]');
  }
}

// login.e2e.test.ts
test("should show error for invalid credentials", async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login("invalid@email.com", "wrongpassword");

  const errorMessage = await loginPage.getErrorMessage();
  expect(errorMessage).toBe("Invalid credentials");
});

Test Data Builders

// test-data/builders.ts
export class UserBuilder {
  private user: Partial<User> = {};

  withEmail(email: string) {
    this.user.email = email;
    return this;
  }

  withName(name: string) {
    this.user.name = name;
    return this;
  }

  withRole(role: UserRole) {
    this.user.role = role;
    return this;
  }

  build(): User {
    return {
      id: "test-id",
      email: "test@example.com",
      name: "Test User",
      role: "user",
      ...this.user,
    } as User;
  }
}

// Usage
const user = new UserBuilder()
  .withEmail("john@example.com")
  .withName("John Doe")
  .withRole("admin")
  .build();

Continuous Integration Testing

GitHub Actions Test Workflow

name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [16.x, 18.x]

    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run linting
        run: npm run lint

      - name: Run unit tests
        run: npm run test:unit

      - name: Run integration tests
        run: npm run test:integration

      - name: Run E2E tests
        run: npm run test:e2e

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info

Test Coverage

// vitest.config.ts
/// <reference types="vitest" />
import { defineConfig } from "vite";

export default defineConfig({
  test: {
    coverage: {
      reporter: ["text", "json", "html", "lcov"],
      exclude: [
        "node_modules/",
        "src/test/",
        "**/*.d.ts",
        "cypress/",
        "src/**/*.test.{ts,tsx}",
      ],
      thresholds: {
        global: {
          branches: 80,
          functions: 80,
          lines: 80,
          statements: 80,
        },
      },
    },
  },
});

Performance Testing

Load Testing with Artillery

# artillery.yml
config:
  target: "http://localhost:3000"
  phases:
    - duration: 60
      arrivalRate: 5
      name: "Warm up"
    - duration: 120
      arrivalRate: 5
      rampTo: 50
      name: "Ramp up load"
    - duration: 60
      arrivalRate: 50
      name: "Sustained load"

scenarios:
  - name: "User registration"
    weight: 3
    flow:
      - post:
          url: "/api/users"
          json:
            email: "user{{ $randomInt }}@example.com"
            name: "Test User {{ $randomInt }}"
          expect:
            - statusCode: 201

  - name: "Get users"
    weight: 7
    flow:
      - get:
          url: "/api/users"

Conclusion

A well-structured testing strategy with the right balance of unit, integration, and end-to-end tests ensures software reliability and maintainability. Following TDD practices, using appropriate mocking strategies, and maintaining good test coverage leads to more robust applications and faster development cycles.