Skip to content
Published on

Complete Software Testing Guide 2025: TDD, Unit/Integration/E2E Testing, and CI/CD Automation

Authors

1. Why Testing Matters

The cost of fixing bugs found in production is over 100x higher than when found during development (IBM Systems Sciences Institute study). Testing is not just about finding bugs -- it provides software quality assurance, refactoring safety nets, and serves as living documentation.

Core Values of Testing

  • Confident deployments: When tests pass, you can deploy with confidence
  • Refactoring freedom: Improve code while guaranteeing existing behavior
  • Living documentation: Test code demonstrates how features are used
  • Design improvement: Code that is hard to test signals poor design
  • Regression prevention: Ensures existing features remain intact when adding new ones

2. The Test Pyramid

The test pyramid, proposed by Martin Fowler, is the core framework for testing strategy.

        /\
       /  \        E2E Tests (few)
      /    \       - Slow and expensive
     /------\      - Validates entire system
    /        \
   / Integration\  Integration Tests (moderate)
  /    Tests     \ - Component interactions
 /--------------\ - DB, API integration tests
/                \
/   Unit Tests    \ Unit Tests (many)
/------------------\ - Fast and stable
                     - Individual function/class verification
Test TypeRatioExecution SpeedMaintenance CostReliability
Unit70%Very fastLowMedium
Integration20%ModerateMediumHigh
E2E10%SlowHighVery high

3. TDD (Test-Driven Development)

3.1 Red-Green-Refactor Cycle

TDD consists of three repeating steps:

  1. Red: Write a failing test first
  2. Green: Write minimal code to make the test pass
  3. Refactor: Clean up the code while keeping tests passing

3.2 TDD Practical Example (TypeScript + Jest)

Developing an email validation function with TDD.

Step 1: Red - Write a failing test

// email-validator.test.ts
import { validateEmail } from './email-validator';

describe('validateEmail', () => {
  it('should return true for valid email', () => {
    expect(validateEmail('user@example.com')).toBe(true);
  });

  it('should return false for email without @', () => {
    expect(validateEmail('userexample.com')).toBe(false);
  });

  it('should return false for email without domain', () => {
    expect(validateEmail('user@')).toBe(false);
  });

  it('should return false for empty string', () => {
    expect(validateEmail('')).toBe(false);
  });

  it('should return false for email with spaces', () => {
    expect(validateEmail('user @example.com')).toBe(false);
  });
});

Step 2: Green - Minimal implementation

// email-validator.ts
export function validateEmail(email: string): boolean {
  if (!email || email.includes(' ')) return false;
  const parts = email.split('@');
  if (parts.length !== 2) return false;
  const [local, domain] = parts;
  return local.length > 0 && domain.includes('.');
}

Step 3: Refactor - Improve with regex

// email-validator.ts (refactored)
export function validateEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

3.3 TDD Practical Example (Python + pytest)

# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
    def setup_method(self):
        self.calc = Calculator()

    def test_add(self):
        assert self.calc.add(2, 3) == 5

    def test_add_negative(self):
        assert self.calc.add(-1, -2) == -3

    def test_divide(self):
        assert self.calc.divide(10, 2) == 5.0

    def test_divide_by_zero(self):
        with pytest.raises(ValueError, match="Cannot divide by zero"):
            self.calc.divide(10, 0)

    def test_divide_returns_float(self):
        assert isinstance(self.calc.divide(7, 2), float)
# calculator.py
class Calculator:
    def add(self, a: int, b: int) -> int:
        return a + b

    def divide(self, a: int, b: int) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return float(a) / b

4. Unit Testing

4.1 Jest (TypeScript/JavaScript)

// user-service.test.ts
import { UserService } from './user-service';
import { UserRepository } from './user-repository';

jest.mock('./user-repository');
const MockedUserRepo = UserRepository as jest.MockedClass<typeof UserRepository>;

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

  beforeEach(() => {
    mockRepo = new MockedUserRepo() as jest.Mocked<UserRepository>;
    service = new UserService(mockRepo);
    jest.clearAllMocks();
  });

  describe('getUser', () => {
    it('should return user when found', async () => {
      const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
      mockRepo.findById.mockResolvedValue(mockUser);

      const result = await service.getUser('1');

      expect(result).toEqual(mockUser);
      expect(mockRepo.findById).toHaveBeenCalledWith('1');
      expect(mockRepo.findById).toHaveBeenCalledTimes(1);
    });

    it('should throw when user not found', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.getUser('999')).rejects.toThrow('User not found');
    });
  });

  describe('createUser', () => {
    it('should create user with hashed password', async () => {
      const input = { name: 'Bob', email: 'bob@test.com', password: 'secret' };
      mockRepo.create.mockResolvedValue({ id: '2', ...input, password: 'hashed' });

      const result = await service.createUser(input);

      expect(result.id).toBe('2');
      expect(mockRepo.create).toHaveBeenCalledTimes(1);
    });

    it('should throw for duplicate email', async () => {
      mockRepo.findByEmail.mockResolvedValue({ id: '1', email: 'bob@test.com' } as any);

      await expect(
        service.createUser({ name: 'Bob', email: 'bob@test.com', password: 'x' })
      ).rejects.toThrow('Email already exists');
    });
  });
});

4.2 pytest (Python)

# test_order_service.py
import pytest
from unittest.mock import MagicMock, patch
from order_service import OrderService

class TestOrderService:
    def setup_method(self):
        self.mock_repo = MagicMock()
        self.mock_payment = MagicMock()
        self.service = OrderService(
            repository=self.mock_repo,
            payment_gateway=self.mock_payment
        )

    def test_create_order_success(self):
        self.mock_payment.charge.return_value = {"status": "success", "tx_id": "tx123"}
        self.mock_repo.save.return_value = {"id": "order1", "total": 100}

        result = self.service.create_order(
            user_id="user1",
            items=[{"product_id": "p1", "quantity": 2, "price": 50}]
        )

        assert result["id"] == "order1"
        self.mock_payment.charge.assert_called_once_with(amount=100, user_id="user1")
        self.mock_repo.save.assert_called_once()

    def test_create_order_payment_failure(self):
        self.mock_payment.charge.side_effect = Exception("Payment declined")

        with pytest.raises(Exception, match="Payment declined"):
            self.service.create_order(
                user_id="user1",
                items=[{"product_id": "p1", "quantity": 1, "price": 50}]
            )

        self.mock_repo.save.assert_not_called()

    @pytest.mark.parametrize("items,expected_total", [
        ([{"price": 10, "quantity": 1}], 10),
        ([{"price": 10, "quantity": 3}], 30),
        ([{"price": 10, "quantity": 2}, {"price": 20, "quantity": 1}], 40),
    ])
    def test_calculate_total(self, items, expected_total):
        total = self.service.calculate_total(items)
        assert total == expected_total

4.3 JUnit 5 (Java)

// UserServiceTest.java
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private UserService userService;

    @Test
    @DisplayName("Should return user when valid ID is provided")
    void getUser_ValidId_ReturnsUser() {
        User expected = new User("1", "Alice", "alice@test.com");
        when(userRepository.findById("1")).thenReturn(Optional.of(expected));

        User result = userService.getUser("1");

        assertThat(result.getName()).isEqualTo("Alice");
        verify(userRepository).findById("1");
    }

    @Test
    @DisplayName("Should throw exception when user not found")
    void getUser_InvalidId_ThrowsException() {
        when(userRepository.findById("999")).thenReturn(Optional.empty());

        assertThatThrownBy(() -> userService.getUser("999"))
            .isInstanceOf(UserNotFoundException.class)
            .hasMessage("User not found: 999");
    }

    @Test
    @DisplayName("Should create user with encoded password")
    void createUser_ValidInput_CreatesUserWithEncodedPassword() {
        when(passwordEncoder.encode("rawPassword")).thenReturn("encodedPassword");
        when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));

        User result = userService.createUser("Bob", "bob@test.com", "rawPassword");

        assertThat(result.getPassword()).isEqualTo("encodedPassword");
        verify(passwordEncoder).encode("rawPassword");
    }
}

5. Mocking Strategies

5.1 Mock vs Stub vs Spy

TypePurposeVerification
MockVerify calls and argumentsBehavior verification (verify)
StubReturn predetermined valuesState verification (assert)
SpyCall real implementation + recordBehavior + state verification

5.2 Jest Mocking Patterns

// Module mocking
jest.mock('./api-client', () => ({
  fetchData: jest.fn().mockResolvedValue({ data: 'mocked' }),
}));

// Timer mocking
jest.useFakeTimers();
setTimeout(() => callback(), 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();

// fetch mocking
global.fetch = jest.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ users: [] }),
});

5.3 Python unittest.mock Patterns

from unittest.mock import patch, MagicMock
from datetime import datetime

# Decorator pattern
@patch('module.datetime')
def test_greeting_morning(mock_datetime):
    mock_datetime.now.return_value = datetime(2025, 1, 1, 9, 0, 0)
    assert get_greeting() == "Good morning"

# Context manager pattern
def test_api_call():
    with patch('requests.get') as mock_get:
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {"status": "ok"}

        result = fetch_status()
        assert result == "ok"
        mock_get.assert_called_once()

5.4 When to Use Mocking

Appropriate for mocking:

  • External API calls (HTTP, gRPC)
  • Database access
  • File system I/O
  • Time-dependent logic (current time, timers)
  • Random value generation

Avoid mocking:

  • Pure functions (results determined solely by inputs)
  • Value objects (DTOs, Entities)
  • Internal methods of the test subject

6. Integration Testing

6.1 Using Testcontainers

Testcontainers spins up real databases in Docker containers for integration testing.

// user-repository.integration.test.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { UserRepository } from './user-repository';

describe('UserRepository Integration', () => {
  let container: any;
  let pool: Pool;
  let repo: UserRepository;

  beforeAll(async () => {
    container = await new PostgreSqlContainer()
      .withDatabase('testdb')
      .start();

    pool = new Pool({ connectionString: container.getConnectionUri() });
    await pool.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        name VARCHAR(100),
        email VARCHAR(100) UNIQUE
      )
    `);
    repo = new UserRepository(pool);
  }, 60000);

  afterAll(async () => {
    await pool.end();
    await container.stop();
  });

  afterEach(async () => {
    await pool.query('DELETE FROM users');
  });

  it('should save and retrieve user', async () => {
    await repo.create({ name: 'Alice', email: 'alice@test.com' });

    const user = await repo.findByEmail('alice@test.com');

    expect(user).toBeDefined();
    expect(user!.name).toBe('Alice');
  });

  it('should throw on duplicate email', async () => {
    await repo.create({ name: 'Alice', email: 'alice@test.com' });

    await expect(
      repo.create({ name: 'Bob', email: 'alice@test.com' })
    ).rejects.toThrow();
  });
});

6.2 API Integration Testing (supertest)

// app.integration.test.ts
import request from 'supertest';
import { app } from './app';

describe('POST /api/users', () => {
  it('should create user and return 201', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' })
      .expect(201);

    expect(response.body).toMatchObject({
      name: 'Alice',
      email: 'alice@test.com',
    });
    expect(response.body.id).toBeDefined();
  });

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

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

  it('should return 409 for duplicate email', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' });

    await request(app)
      .post('/api/users')
      .send({ name: 'Bob', email: 'alice@test.com' })
      .expect(409);
  });
});

7. E2E Testing

7.1 Playwright vs Cypress Comparison

FeaturePlaywrightCypress
Browser supportChromium, Firefox, WebKitChrome, Firefox, Edge
LanguagesTS/JS, Python, Java, C#JS/TS
Parallel executionNative supportPaid (Cypress Cloud)
Network interceptionPowerful interceptionInterception supported
MobileDevice emulationViewport only
SpeedVery fastFast
DebuggingTrace ViewerTime Travel
CommunityRapidly growingLarge

7.2 Playwright Example

// login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('should login with valid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'user@test.com');
    await page.fill('[data-testid="password-input"]', 'password123');
    await page.click('[data-testid="login-button"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('[data-testid="welcome-message"]')).toContainText('Welcome');
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'wrong@test.com');
    await page.fill('[data-testid="password-input"]', 'wrong');
    await page.click('[data-testid="login-button"]');

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });

  test('should navigate to registration page', async ({ page }) => {
    await page.click('text=Sign up');
    await expect(page).toHaveURL('/register');
  });
});

7.3 Page Object Model (POM)

// pages/login-page.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[data-testid="email-input"]');
    this.passwordInput = page.locator('[data-testid="password-input"]');
    this.loginButton = page.locator('[data-testid="login-button"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

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

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test('should login successfully', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

7.4 Cypress Example

// cypress/e2e/checkout.cy.js
describe('Checkout Flow', () => {
  beforeEach(() => {
    cy.login('user@test.com', 'password');
    cy.visit('/products');
  });

  it('should complete checkout process', () => {
    // Add product
    cy.get('[data-testid="product-card"]').first().click();
    cy.get('[data-testid="add-to-cart"]').click();
    cy.get('[data-testid="cart-count"]').should('contain', '1');

    // Navigate to cart
    cy.get('[data-testid="cart-icon"]').click();
    cy.url().should('include', '/cart');

    // Proceed to checkout
    cy.get('[data-testid="checkout-button"]').click();
    cy.get('[data-testid="shipping-form"]').within(() => {
      cy.get('input[name="address"]').type('123 Main St');
      cy.get('input[name="city"]').type('Seoul');
    });

    cy.get('[data-testid="confirm-order"]').click();
    cy.get('[data-testid="order-confirmation"]').should('be.visible');
    cy.get('[data-testid="order-number"]').should('exist');
  });

  it('should handle empty cart', () => {
    cy.visit('/cart');
    cy.get('[data-testid="empty-cart-message"]').should('be.visible');
    cy.get('[data-testid="checkout-button"]').should('be.disabled');
  });
});

8. Performance Testing

8.1 k6 Load Testing

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

const errorRate = new Rate('errors');
const responseTime = new Trend('response_time');

export const options = {
  stages: [
    { duration: '1m', target: 50 },    // Ramp up to 50 users
    { duration: '3m', target: 50 },    // Stay at 50 users
    { duration: '1m', target: 200 },   // Ramp up to 200 users
    { duration: '3m', target: 200 },   // Stay at 200 users
    { duration: '1m', target: 0 },     // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],   // 95% of requests under 500ms
    errors: ['rate<0.01'],              // Error rate under 1%
  },
};

export default function () {
  const res = http.get('https://api.example.com/products');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'body has products': (r) => JSON.parse(r.body).length > 0,
  });

  errorRate.add(res.status !== 200);
  responseTime.add(res.timings.duration);

  sleep(1);
}

Run commands:

k6 run load-test.js
k6 run --out json=results.json load-test.js    # JSON output
k6 run --vus 100 --duration 30s load-test.js   # Quick smoke test

9. Code Coverage

9.1 Coverage Types

TypeDescriptionImportance
Line CoveragePercentage of executed code linesBasic
Branch CoveragePercentage of executed branches (if/else)High
Function CoveragePercentage of called functionsMedium
Statement CoveragePercentage of executed statementsBasic

9.2 Coverage Tool Configuration

Jest (Istanbul):

{
  "jest": {
    "collectCoverage": true,
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/test/",
      "/__mocks__/"
    ]
  }
}

pytest (coverage.py):

# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=html --cov-report=term-missing --cov-fail-under=80"

[tool.coverage.run]
omit = ["tests/*", "*/migrations/*"]

JaCoCo (Java/Gradle):

// build.gradle
plugins {
    id 'jacoco'
}

jacocoTestReport {
    reports {
        xml.required = true
        html.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.80
            }
        }
    }
}

9.3 The Trap of 100% Coverage

Do not aim for 100% coverage. Here is why:

  • Meaningless tests proliferate: Tests for getters/setters and simple delegation methods add little value
  • Increased maintenance burden: More tests break on implementation changes
  • False sense of security: Executing code is not the same as verifying correctness
  • Recommended targets: Core business logic 90%+, overall project 70-85%

10. CI/CD Test Automation

10.1 GitHub Actions Workflow

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

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

jobs:
  unit-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run lint
      - run: npm run test:unit -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  integration-test:
    runs-on: ubuntu-latest
    needs: unit-test
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb

  e2e-test:
    runs-on: ubuntu-latest
    needs: integration-test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - name: Install Playwright
        run: npx playwright install --with-deps
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

11. Mutation Testing

Mutation testing validates the quality of your tests. It makes small changes (mutants) to source code and checks whether your tests detect those changes.

11.1 Stryker (JavaScript/TypeScript)

{
  "stryker": {
    "mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
    "testRunner": "jest",
    "reporters": ["html", "clear-text", "progress"],
    "thresholds": {
      "high": 80,
      "low": 60,
      "break": 50
    }
  }
}
npx stryker run

Mutation examples:

  • Condition inversion: if (a > b) changed to if (a <= b)
  • Operator change: a + b changed to a - b
  • Return value change: return true changed to return false

If a mutant survives (tests still pass), it means that area has insufficient test coverage.


12. Contract Testing

12.1 Pact Basics

Contract testing verifies API contracts between microservices. It confirms that the response a Consumer expects matches what the Provider actually sends.

// consumer.test.ts (Consumer side)
import { PactV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'UserService',
});

describe('User API Contract', () => {
  it('should return user details', async () => {
    await provider
      .given('user with ID 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({
        method: 'GET',
        path: '/api/users/1',
      })
      .willRespondWith({
        status: 200,
        body: {
          id: '1',
          name: 'Alice',
          email: 'alice@example.com',
        },
      })
      .executeTest(async (mockserver) => {
        const response = await fetch(`${mockserver.url}/api/users/1`);
        const data = await response.json();
        expect(data.name).toBe('Alice');
      });
  });
});

13. AI-Assisted Testing

How AI can help with test writing.

13.1 AI Testing Tool Usage

  • GitHub Copilot: Autocompletes test bodies when you write test function signatures
  • ChatGPT/Claude: Generates test code including edge cases when given a function
  • Codium AI: Analyzes code and auto-generates meaningful tests

13.2 Limitations of AI Testing

  • May not fully understand the intent of business logic
  • Generated tests may couple to implementation details
  • Edge case importance judgment may be inaccurate
  • Conclusion: AI is an assistive tool; test design remains the developer's domain

14. Testing Anti-Patterns

14.1 Ten Patterns to Avoid

  1. Ice Cream Cone: Inverted pyramid with many E2E and few Unit tests. Slow and unstable.

  2. Testing implementation details: Directly testing internal methods. Breaks on refactoring.

  3. Tests that never fail: Tests with no assertions or that always pass.

  4. Interdependent tests: Results change depending on execution order.

  5. Slow tests: Entire test suite taking 10+ minutes. Developers avoid running them.

  6. Flaky Tests: Sometimes pass, sometimes fail with same code. Caused by timing and external dependencies.

  7. Over-mocking: Too many mocks create divergence from actual behavior.

  8. Magic numbers: Hard-coded values of unclear meaning in tests. Reduces readability.

  9. Test duplication: Same logic repeated across multiple tests. Apply DRY principle.

  10. Commented-out tests: Commenting out failing tests. Delete or fix them instead.

14.2 Handling Flaky Tests

// Bad: Test dependent on timing
test('should show toast after action', async () => {
  await page.click('#submit');
  await page.waitForTimeout(2000);  // Bad: hard-coded wait time
  expect(await page.isVisible('.toast')).toBe(true);
});

// Good: Condition-based waiting
test('should show toast after action', async () => {
  await page.click('#submit');
  await expect(page.locator('.toast')).toBeVisible({ timeout: 5000 });
});

15. Interview Questions (15)

Q1. What are the characteristics of each test pyramid level and appropriate ratios?

Unit Test (70%): Tests individual functions/classes in isolation. Very fast and stable. External dependencies are mocked.

Integration Test (20%): Tests interactions between components. Verifies actual DB, API integrations. Slower than unit tests but closer to real environment.

E2E Test (10%): Tests entire user scenarios. Runs in browser. Slowest and most unstable but highest reliability.

Q2. What are the pros and cons of TDD?

Pros: High test coverage, better design (testable code), fast feedback, refactoring confidence, reduced debugging time.

Cons: Slower initial development, learning curve, unsuitable for frequently changing prototypes, difficult to apply to legacy code.

Q3. What is the difference between Mock and Stub?

Mock: Verifies behavior. Checks whether methods were called with specific arguments and how many times. (verify)

Stub: Returns predetermined values. Fixes external dependency responses to verify state. (assert)

Key difference: Mocks focus on "how was it called", Stubs focus on "is the result correct".

Q4. Why is 100% code coverage not ideal?
  1. Proliferates meaningless tests (getters/setters)
  2. Increases maintenance costs
  3. Code execution and correctness verification are different things
  4. Distracts from focusing on critical business logic

Recommended: Core business logic 90%+, overall 70-85%. Prioritize branch coverage over line coverage.

Q5. What is a Flaky Test? How do you fix it?

A test that sometimes passes and sometimes fails with the same code.

Causes: Timing dependencies, external service dependencies, shared state between tests, non-deterministic data.

Solutions:

  • Use condition-based waiting instead of hard-coded sleep/timeout
  • Mock external dependencies
  • Use independent data per test
  • Configure retries in CI
  • Quarantine and fix
Q6. What are the advantages of Page Object Model in E2E testing?
  1. Code reuse: Page elements and actions managed in one place
  2. Easy maintenance: Only POM needs updating when UI changes
  3. Improved readability: Test code focuses on business logic
  4. Eliminates duplication: Same page manipulation code not scattered across tests
Q7. Where is the boundary between integration tests and unit tests?

Unit test: Runs in-memory without external dependencies. All I/O is mocked. Verifies a single "unit" (function, class).

Integration test: Interacts with real external systems (DB, API, file system). Verifies integration between components. Uses Testcontainers, test DBs, etc.

Boundary: "Does it communicate with an external process/service?" If yes, it is integration; if not, it is unit.

Q8. What is Mutation Testing?

A technique that makes intentional small changes (mutants) to source code and verifies whether existing tests detect the changes. If tests detect the change, the mutant is "killed"; if not, it "survived". Many surviving mutants indicate low test quality.

Tools: Stryker (JS/TS), PIT (Java), mutmut (Python)

Q9. When is Contract Testing needed?

When verifying API contracts between services in a microservices architecture. Confirms that the response format expected by Consumers is maintained even when Providers change their APIs.

Benefits: Faster and more stable than E2E, enables independent service deployment, identifies API change impact in advance.

Tools: Pact, Spring Cloud Contract

Q10. What is Test Isolation?

Each test can execute independently without being affected by other tests. Must guarantee the same results regardless of execution order.

Methods: Reset state with beforeEach/afterEach, use unique data per test, avoid global state changes, use DB transaction rollbacks.

Q11. What is the difference between BDD and TDD?

TDD: Developer perspective. Verifies code-level behavior. Red-Green-Refactor cycle.

BDD (Behavior-Driven Development): Business perspective. Describes user scenarios in Given-When-Then format. Tests are readable by non-developers.

BDD is an extension of TDD that derives tests from business requirements.

Q12. What are the types of Test Doubles?
  1. Dummy: Object used just to fill parameters, never actually used
  2. Stub: Object that returns predetermined values
  3. Spy: Object that records call information while executing real implementation
  4. Mock: Object with preset expectations that are verified
  5. Fake: Simplified version of real implementation (e.g., in-memory DB)
Q13. Explain the types of performance testing.
  1. Load Test: Verify performance under expected load
  2. Stress Test: Verify behavior beyond capacity limits
  3. Spike Test: Verify response to sudden load increases
  4. Soak Test: Verify stability under sustained load over time (memory leaks, etc.)
  5. Benchmark: Measure baseline performance
Q14. What are characteristics of testable code?
  1. Dependency Injection (DI): External dependencies injected via constructors or methods
  2. Single Responsibility Principle: Each class/function performs one role
  3. Pure functions: Same input yields same output, no side effects
  4. Interface usage: Depend on abstractions, not implementations
  5. Avoid global state: Minimize singletons and global variables

If code is hard to test, it signals that the design needs reconsideration.

Q15. What is Regression Testing?

Testing that verifies new code changes have not broken existing functionality. All existing tests are re-run to detect side effects of changes.

Running automatically in CI/CD is key. Every time a bug is discovered, adding a test that reproduces it strengthens the regression test suite.


16. Quiz

Q1. Find the error in this Jest test.
test('should fetch user data', () => {
  const result = fetchUser('1');
  expect(result.name).toBe('Alice');
});

Answer: If fetchUser is an async function (returns a Promise), await is needed.

test('should fetch user data', async () => {
  const result = await fetchUser('1');
  expect(result.name).toBe('Alice');
});

Without async/await, the test passes before the assertion executes when a Promise is returned.

Q2. Which test pyramid level does this belong to?

"A test that spins up PostgreSQL with Testcontainers and verifies UserRepository CRUD operations"

Answer: Integration Test

It uses a real database (Docker container) and verifies the interaction between Repository and DB, making it an integration test. If it used an in-memory mock DB, it would be closer to a unit test.

Q3. What is the AAA pattern?

Answer: Stands for Arrange-Act-Assert, representing the structure of test code.

  1. Arrange: Prepare data and objects needed for the test
  2. Act: Execute the method under test
  3. Assert: Verify the results

Following this pattern makes test intent clear. In BDD, Given-When-Then represents the same concept.

Q4. Which of the following is NOT a cause of Flaky Tests?

A) Hard-coded sleep wait times B) Shared database state between tests C) Mocking using dependency injection D) Tests that directly depend on external APIs

Answer: C

Mocking through dependency injection actually makes tests more stable. A, B, and D are all classic causes of Flaky Tests.

Q5. Why can bugs exist even with 100% Branch Coverage?

Answer: Branch Coverage only confirms that all code branches (if/else) were executed. It does not verify:

  1. Boundary values (off-by-one errors)
  2. Individual condition combinations within compound conditions
  3. Exceptional inputs (null, empty strings, negative numbers)
  4. Concurrency issues (race conditions)
  5. Correctness of business logic

Coverage is a necessary condition, not a sufficient one.


17. References

Official Documentation

  1. Jest Documentation - JavaScript testing framework
  2. pytest Documentation - Python testing framework
  3. JUnit 5 User Guide - Java testing
  4. Playwright Documentation - E2E testing
  5. Cypress Documentation - E2E testing

Tools and Libraries

  1. Testcontainers - Docker-based integration testing
  2. k6 - Performance/load testing
  3. Stryker Mutator - Mutation testing
  4. Pact - Contract testing
  5. Istanbul/nyc - JavaScript code coverage

Books and Learning

  1. Test Driven Development: By Example (Kent Beck) - The TDD bible
  2. Growing Object-Oriented Software, Guided by Tests (Freeman, Pryce) - OO TDD
  3. The Art of Unit Testing (Roy Osherove) - Practical unit testing
  4. xUnit Test Patterns (Gerard Meszaros) - Test pattern reference
  5. Testing Trophy by Kent C. Dodds - Frontend testing strategy

CI/CD and Quality

  1. GitHub Actions Testing Guide - CI test automation
  2. Codecov - Coverage reporting
  3. SonarQube - Code quality analysis