Files
pig-farm-controller/bmad/bmm/testarch/knowledge/test-levels-framework.md
2025-11-01 19:22:39 +08:00

14 KiB

Test Levels Framework

Comprehensive guide for determining appropriate test levels (unit, integration, E2E) for different scenarios.

Test Level Decision Matrix

Unit Tests

When to use:

  • Testing pure functions and business logic
  • Algorithm correctness
  • Input validation and data transformation
  • Error handling in isolated components
  • Complex calculations or state machines

Characteristics:

  • Fast execution (immediate feedback)
  • No external dependencies (DB, API, file system)
  • Highly maintainable and stable
  • Easy to debug failures

Example scenarios:

unit_test:
  component: 'PriceCalculator'
  scenario: 'Calculate discount with multiple rules'
  justification: 'Complex business logic with multiple branches'
  mock_requirements: 'None - pure function'

Integration Tests

When to use:

  • Component interaction verification
  • Database operations and transactions
  • API endpoint contracts
  • Service-to-service communication
  • Middleware and interceptor behavior

Characteristics:

  • Moderate execution time
  • Tests component boundaries
  • May use test databases or containers
  • Validates system integration points

Example scenarios:

integration_test:
  components: ['UserService', 'AuthRepository']
  scenario: 'Create user with role assignment'
  justification: 'Critical data flow between service and persistence'
  test_environment: 'In-memory database'

End-to-End Tests

When to use:

  • Critical user journeys
  • Cross-system workflows
  • Visual regression testing
  • Compliance and regulatory requirements
  • Final validation before release

Characteristics:

  • Slower execution
  • Tests complete workflows
  • Requires full environment setup
  • Most realistic but most brittle

Example scenarios:

e2e_test:
  journey: 'Complete checkout process'
  scenario: 'User purchases with saved payment method'
  justification: 'Revenue-critical path requiring full validation'
  environment: 'Staging with test payment gateway'

Test Level Selection Rules

Favor Unit Tests When:

  • Logic can be isolated
  • No side effects involved
  • Fast feedback needed
  • High cyclomatic complexity

Favor Integration Tests When:

  • Testing persistence layer
  • Validating service contracts
  • Testing middleware/interceptors
  • Component boundaries critical

Favor E2E Tests When:

  • User-facing critical paths
  • Multi-system interactions
  • Regulatory compliance scenarios
  • Visual regression important

Anti-patterns to Avoid

  • E2E testing for business logic validation
  • Unit testing framework behavior
  • Integration testing third-party libraries
  • Duplicate coverage across levels

Duplicate Coverage Guard

Before adding any test, check:

  1. Is this already tested at a lower level?
  2. Can a unit test cover this instead of integration?
  3. Can an integration test cover this instead of E2E?

Coverage overlap is only acceptable when:

  • Testing different aspects (unit: logic, integration: interaction, e2e: user experience)
  • Critical paths requiring defense in depth
  • Regression prevention for previously broken functionality

Test Naming Conventions

  • Unit: test_{component}_{scenario}
  • Integration: test_{flow}_{interaction}
  • E2E: test_{journey}_{outcome}

Test ID Format

{EPIC}.{STORY}-{LEVEL}-{SEQ}

Examples:

  • 1.3-UNIT-001
  • 1.3-INT-002
  • 1.3-E2E-001

Real Code Examples

Example 1: E2E Test (Full User Journey)

Scenario: User logs in, navigates to dashboard, and places an order.

// tests/e2e/checkout-flow.spec.ts
import { test, expect } from '@playwright/test';
import { createUser, createProduct } from '../test-utils/factories';

test.describe('Checkout Flow', () => {
  test('user can complete purchase with saved payment method', async ({ page, apiRequest }) => {
    // Setup: Seed data via API (fast!)
    const user = createUser({ email: 'buyer@example.com', hasSavedCard: true });
    const product = createProduct({ name: 'Widget', price: 29.99, stock: 10 });

    await apiRequest.post('/api/users', { data: user });
    await apiRequest.post('/api/products', { data: product });

    // Network-first: Intercept BEFORE action
    const loginPromise = page.waitForResponse('**/api/auth/login');
    const cartPromise = page.waitForResponse('**/api/cart');
    const orderPromise = page.waitForResponse('**/api/orders');

    // Step 1: Login
    await page.goto('/login');
    await page.fill('[data-testid="email"]', user.email);
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="login-button"]');
    await loginPromise;

    // Assert: Dashboard visible
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText(`Welcome, ${user.name}`)).toBeVisible();

    // Step 2: Add product to cart
    await page.goto(`/products/${product.id}`);
    await page.click('[data-testid="add-to-cart"]');
    await cartPromise;
    await expect(page.getByText('Added to cart')).toBeVisible();

    // Step 3: Checkout with saved payment
    await page.goto('/checkout');
    await expect(page.getByText('Visa ending in 1234')).toBeVisible(); // Saved card
    await page.click('[data-testid="use-saved-card"]');
    await page.click('[data-testid="place-order"]');
    await orderPromise;

    // Assert: Order confirmation
    await expect(page.getByText('Order Confirmed')).toBeVisible();
    await expect(page.getByText(/Order #\d+/)).toBeVisible();
    await expect(page.getByText('$29.99')).toBeVisible();
  });
});

Key Points (E2E):

  • Tests complete user journey across multiple pages
  • API setup for data (fast), UI for assertions (user-centric)
  • Network-first interception to prevent flakiness
  • Validates critical revenue path end-to-end

Example 2: Integration Test (API/Service Layer)

Scenario: UserService creates user and assigns role via AuthRepository.

// tests/integration/user-service.spec.ts
import { test, expect } from '@playwright/test';
import { createUser } from '../test-utils/factories';

test.describe('UserService Integration', () => {
  test('should create user with admin role via API', async ({ request }) => {
    const userData = createUser({ role: 'admin' });

    // Direct API call (no UI)
    const response = await request.post('/api/users', {
      data: userData,
    });

    expect(response.status()).toBe(201);

    const createdUser = await response.json();
    expect(createdUser.id).toBeTruthy();
    expect(createdUser.email).toBe(userData.email);
    expect(createdUser.role).toBe('admin');

    // Verify database state
    const getResponse = await request.get(`/api/users/${createdUser.id}`);
    expect(getResponse.status()).toBe(200);

    const fetchedUser = await getResponse.json();
    expect(fetchedUser.role).toBe('admin');
    expect(fetchedUser.permissions).toContain('user:delete');
    expect(fetchedUser.permissions).toContain('user:update');

    // Cleanup
    await request.delete(`/api/users/${createdUser.id}`);
  });

  test('should validate email uniqueness constraint', async ({ request }) => {
    const userData = createUser({ email: 'duplicate@example.com' });

    // Create first user
    const response1 = await request.post('/api/users', { data: userData });
    expect(response1.status()).toBe(201);

    const user1 = await response1.json();

    // Attempt duplicate email
    const response2 = await request.post('/api/users', { data: userData });
    expect(response2.status()).toBe(409); // Conflict
    const error = await response2.json();
    expect(error.message).toContain('Email already exists');

    // Cleanup
    await request.delete(`/api/users/${user1.id}`);
  });
});

Key Points (Integration):

  • Tests service layer + database interaction
  • No UI involved—pure API validation
  • Business logic focus (role assignment, constraints)
  • Faster than E2E, more realistic than unit tests

Example 3: Component Test (Isolated UI Component)

Scenario: Test button component in isolation with props and user interactions.

// src/components/Button.cy.tsx (Cypress Component Test)
import { Button } from './Button';

describe('Button Component', () => {
  it('should render with correct label', () => {
    cy.mount(<Button label="Click Me" />);
    cy.contains('Click Me').should('be.visible');
  });

  it('should call onClick handler when clicked', () => {
    const onClickSpy = cy.stub().as('onClick');
    cy.mount(<Button label="Submit" onClick={onClickSpy} />);

    cy.get('button').click();
    cy.get('@onClick').should('have.been.calledOnce');
  });

  it('should be disabled when disabled prop is true', () => {
    cy.mount(<Button label="Disabled" disabled={true} />);
    cy.get('button').should('be.disabled');
    cy.get('button').should('have.attr', 'aria-disabled', 'true');
  });

  it('should show loading spinner when loading', () => {
    cy.mount(<Button label="Loading" loading={true} />);
    cy.get('[data-testid="spinner"]').should('be.visible');
    cy.get('button').should('be.disabled');
  });

  it('should apply variant styles correctly', () => {
    cy.mount(<Button label="Primary" variant="primary" />);
    cy.get('button').should('have.class', 'btn-primary');

    cy.mount(<Button label="Secondary" variant="secondary" />);
    cy.get('button').should('have.class', 'btn-secondary');
  });
});

// Playwright Component Test equivalent
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test.describe('Button Component', () => {
  test('should call onClick handler when clicked', async ({ mount }) => {
    let clicked = false;
    const component = await mount(
      <Button label="Submit" onClick={() => { clicked = true; }} />
    );

    await component.getByRole('button').click();
    expect(clicked).toBe(true);
  });

  test('should be disabled when loading', async ({ mount }) => {
    const component = await mount(<Button label="Loading" loading={true} />);
    await expect(component.getByRole('button')).toBeDisabled();
    await expect(component.getByTestId('spinner')).toBeVisible();
  });
});

Key Points (Component):

  • Tests UI component in isolation (no full app)
  • Props + user interactions + visual states
  • Faster than E2E, more realistic than unit tests for UI
  • Great for design system components

Example 4: Unit Test (Pure Function)

Scenario: Test pure business logic function without framework dependencies.

// src/utils/price-calculator.test.ts (Jest/Vitest)
import { calculateDiscount, applyTaxes, calculateTotal } from './price-calculator';

describe('PriceCalculator', () => {
  describe('calculateDiscount', () => {
    it('should apply percentage discount correctly', () => {
      const result = calculateDiscount(100, { type: 'percentage', value: 20 });
      expect(result).toBe(80);
    });

    it('should apply fixed amount discount correctly', () => {
      const result = calculateDiscount(100, { type: 'fixed', value: 15 });
      expect(result).toBe(85);
    });

    it('should not apply discount below zero', () => {
      const result = calculateDiscount(10, { type: 'fixed', value: 20 });
      expect(result).toBe(0);
    });

    it('should handle no discount', () => {
      const result = calculateDiscount(100, { type: 'none', value: 0 });
      expect(result).toBe(100);
    });
  });

  describe('applyTaxes', () => {
    it('should calculate tax correctly for US', () => {
      const result = applyTaxes(100, { country: 'US', rate: 0.08 });
      expect(result).toBe(108);
    });

    it('should calculate tax correctly for EU (VAT)', () => {
      const result = applyTaxes(100, { country: 'DE', rate: 0.19 });
      expect(result).toBe(119);
    });

    it('should handle zero tax rate', () => {
      const result = applyTaxes(100, { country: 'US', rate: 0 });
      expect(result).toBe(100);
    });
  });

  describe('calculateTotal', () => {
    it('should calculate total with discount and taxes', () => {
      const items = [
        { price: 50, quantity: 2 }, // 100
        { price: 30, quantity: 1 }, // 30
      ];
      const discount = { type: 'percentage', value: 10 }; // -13
      const tax = { country: 'US', rate: 0.08 }; // +9.36

      const result = calculateTotal(items, discount, tax);
      expect(result).toBeCloseTo(126.36, 2);
    });

    it('should handle empty items array', () => {
      const result = calculateTotal([], { type: 'none', value: 0 }, { country: 'US', rate: 0 });
      expect(result).toBe(0);
    });

    it('should calculate correctly without discount or tax', () => {
      const items = [{ price: 25, quantity: 4 }];
      const result = calculateTotal(items, { type: 'none', value: 0 }, { country: 'US', rate: 0 });
      expect(result).toBe(100);
    });
  });
});

Key Points (Unit):

  • Pure function testing—no framework dependencies
  • Fast execution (milliseconds)
  • Edge case coverage (zero, negative, empty inputs)
  • High cyclomatic complexity handled at unit level

When to Use Which Level

Scenario Unit Integration E2E
Pure business logic Primary Overkill Overkill
Database operations Can't test Primary Overkill
API contracts Can't test Primary ⚠️ Supplement
User journeys Can't test Can't test Primary
Component props/events Partial ⚠️ Component test Overkill
Visual regression Can't test ⚠️ Component test Primary
Error handling (logic) Primary ⚠️ Integration Overkill
Error handling (UI) Partial ⚠️ Component test Primary

Anti-Pattern Examples

BAD: E2E test for business logic

// DON'T DO THIS
test('calculate discount via UI', async ({ page }) => {
  await page.goto('/calculator');
  await page.fill('[data-testid="price"]', '100');
  await page.fill('[data-testid="discount"]', '20');
  await page.click('[data-testid="calculate"]');
  await expect(page.getByText('$80')).toBeVisible();
});
// Problem: Slow, brittle, tests logic that should be unit tested

GOOD: Unit test for business logic

test('calculate discount', () => {
  expect(calculateDiscount(100, 20)).toBe(80);
});
// Fast, reliable, isolated

Source: Murat Testing Philosophy (test pyramid), existing test-levels-framework.md structure.