Files
2025-11-30 08:50:59 +08:00

14 KiB

Testing Patterns for TDD

This document provides language-agnostic testing patterns and best practices that support effective Test-Driven Development across different programming environments.

Test Structure Patterns

The AAA Pattern (Arrange-Act-Assert)

The most fundamental testing pattern. Every test should follow this three-part structure:

Arrange: Set up the test data and preconditions
Act: Execute the behavior being tested
Assert: Verify the expected outcome

Example (Python):

def test_should_add_item_to_shopping_cart():
    # Arrange
    cart = ShoppingCart()
    item = Item("Book", price=29.99)

    # Act
    cart.add_item(item)

    # Assert
    assert len(cart.items) == 1
    assert cart.items[0] == item

Example (JavaScript):

test('should add item to shopping cart', () => {
    // Arrange
    const cart = new ShoppingCart();
    const item = new Item("Book", 29.99);

    // Act
    cart.addItem(item);

    // Assert
    expect(cart.items).toHaveLength(1);
    expect(cart.items[0]).toBe(item);
});

Given-When-Then (BDD Style)

An alternative to AAA, commonly used in Behavior-Driven Development:

Given: Preconditions and context
When: The action or event
Then: Expected outcomes

Example (Ruby):

describe 'Shopping Cart' do
  it 'adds item to cart' do
    # Given a cart and an item
    cart = ShoppingCart.new
    item = Item.new("Book", 29.99)

    # When adding the item
    cart.add_item(item)

    # Then the cart should contain the item
    expect(cart.items.length).to eq(1)
    expect(cart.items[0]).to eq(item)
  end
end

Test Organization Patterns

Test Fixture Pattern

Use setup/teardown to create consistent test preconditions:

Example (Python with pytest):

import pytest

@pytest.fixture
def cart():
    """Create a fresh shopping cart for each test"""
    return ShoppingCart()

@pytest.fixture
def sample_items():
    """Create sample items for testing"""
    return [
        Item("Book", 29.99),
        Item("Pen", 1.99),
        Item("Notebook", 5.99)
    ]

def test_should_calculate_correct_total(cart, sample_items):
    for item in sample_items:
        cart.add_item(item)

    total = cart.calculate_total()

    assert total == 37.97

Example (JavaScript with Jest):

describe('ShoppingCart', () => {
    let cart;

    beforeEach(() => {
        cart = new ShoppingCart();
    });

    test('should calculate correct total', () => {
        cart.addItem(new Item("Book", 29.99));
        cart.addItem(new Item("Pen", 1.99));

        const total = cart.calculateTotal();

        expect(total).toBe(31.98);
    });
});

Test Builder Pattern

Create fluent APIs for complex test data setup:

Example (Java):

public class OrderBuilder {
    private Customer customer;
    private List<Item> items = new ArrayList<>();
    private PaymentMethod paymentMethod;

    public OrderBuilder withCustomer(String name, String email) {
        this.customer = new Customer(name, email);
        return this;
    }

    public OrderBuilder withItem(String name, double price) {
        this.items.add(new Item(name, price));
        return this;
    }

    public OrderBuilder withPayment(PaymentMethod method) {
        this.paymentMethod = method;
        return this;
    }

    public Order build() {
        Order order = new Order(customer);
        items.forEach(order::addItem);
        order.setPaymentMethod(paymentMethod);
        return order;
    }
}

// Usage in tests:
@Test
public void shouldProcessOrderSuccessfully() {
    Order order = new OrderBuilder()
        .withCustomer("John Doe", "john@example.com")
        .withItem("Book", 29.99)
        .withItem("Pen", 1.99)
        .withPayment(PaymentMethod.CREDIT_CARD)
        .build();

    OrderResult result = processor.process(order);

    assertEquals(OrderStatus.COMPLETED, result.getStatus());
}

Object Mother Pattern

Centralized factory for creating common test objects:

Example (Python):

class CustomerMother:
    """Factory for creating test customers"""

    @staticmethod
    def create_standard_customer():
        return Customer(
            name="John Doe",
            email="john@example.com",
            is_active=True
        )

    @staticmethod
    def create_vip_customer():
        return Customer(
            name="Jane Smith",
            email="jane@example.com",
            is_active=True,
            membership_level="VIP"
        )

    @staticmethod
    def create_inactive_customer():
        return Customer(
            name="Bob Wilson",
            email="bob@example.com",
            is_active=False
        )

# Usage in tests:
def test_should_apply_vip_discount():
    customer = CustomerMother.create_vip_customer()
    cart = ShoppingCart(customer)

    cart.add_item(Item("Book", 100))
    total = cart.calculate_total()

    assert total == 80  # 20% VIP discount

Assertion Patterns

Single Assertion Principle

Guideline: Each test should verify one logical concept (though may have multiple assertion statements for clarity).

Good:

def test_should_create_user_with_correct_attributes():
    user = create_user("john@example.com", "John Doe")

    # Multiple assertions verifying one concept: user creation
    assert user.email == "john@example.com"
    assert user.name == "John Doe"
    assert user.is_active is True

Better (when concepts are truly separate):

def test_should_create_user_with_provided_email():
    user = create_user("john@example.com", "John Doe")
    assert user.email == "john@example.com"

def test_should_create_user_with_provided_name():
    user = create_user("john@example.com", "John Doe")
    assert user.name == "John Doe"

def test_should_create_active_user_by_default():
    user = create_user("john@example.com", "John Doe")
    assert user.is_active is True

Custom Assertion Methods

Create domain-specific assertions for clarity:

Example (JavaScript):

function assertValidOrder(order) {
    expect(order.customer).toBeDefined();
    expect(order.items).not.toHaveLength(0);
    expect(order.total).toBeGreaterThan(0);
    expect(order.status).toBe('pending');
}

test('should create valid order from cart', () => {
    const cart = createSampleCart();

    const order = cart.checkout();

    assertValidOrder(order);
});

Test Doubles (Mocking) Patterns

Stub Pattern

Replace dependencies with simplified implementations:

Example (Python):

class StubEmailService:
    """Stub that tracks calls without sending real emails"""
    def __init__(self):
        self.sent_emails = []

    def send(self, to, subject, body):
        self.sent_emails.append({
            'to': to,
            'subject': subject,
            'body': body
        })

def test_should_send_welcome_email_on_registration():
    email_service = StubEmailService()
    user_service = UserService(email_service)

    user = user_service.register("john@example.com", "password")

    assert len(email_service.sent_emails) == 1
    assert email_service.sent_emails[0]['to'] == "john@example.com"
    assert "Welcome" in email_service.sent_emails[0]['subject']

Mock Pattern

Verify interactions with dependencies:

Example (JavaScript with Jest):

test('should call payment gateway with correct amount', () => {
    const mockGateway = {
        charge: jest.fn().mockResolvedValue({ success: true })
    };
    const processor = new PaymentProcessor(mockGateway);

    processor.processPayment(customer, 100.00);

    expect(mockGateway.charge).toHaveBeenCalledWith(
        customer.paymentToken,
        100.00
    );
});

Fake Pattern

Provide working implementations with shortcuts:

Example (Python):

class FakeUserRepository:
    """In-memory repository for testing"""
    def __init__(self):
        self.users = {}
        self.next_id = 1

    def save(self, user):
        if not user.id:
            user.id = self.next_id
            self.next_id += 1
        self.users[user.id] = user
        return user

    def find_by_id(self, user_id):
        return self.users.get(user_id)

def test_should_persist_user_with_generated_id():
    repo = FakeUserRepository()
    user = User(name="John")

    saved_user = repo.save(user)

    assert saved_user.id is not None
    assert repo.find_by_id(saved_user.id) == saved_user

Parameterized Testing Pattern

Test multiple cases with the same structure:

Example (Python with pytest):

import pytest

@pytest.mark.parametrize("input,expected", [
    (0, 0),
    (1, 1),
    (2, 2),
    (3, 6),
    (4, 24),
    (5, 120),
])
def test_factorial_calculation(input, expected):
    result = factorial(input)
    assert result == expected

Example (JavaScript with Jest):

test.each([
    [0, 0],
    [1, 1],
    [2, 2],
    [3, 6],
    [4, 24],
    [5, 120],
])('factorial(%i) should equal %i', (input, expected) => {
    const result = factorial(input);
    expect(result).toBe(expected);
});

Exception Testing Pattern

Test error conditions explicitly:

Example (Python):

def test_should_raise_error_when_withdrawing_too_much():
    account = Account(balance=100)

    with pytest.raises(InsufficientFundsError) as exc_info:
        account.withdraw(150)

    assert "Insufficient funds" in str(exc_info.value)
    assert exc_info.value.available == 100
    assert exc_info.value.requested == 150

Example (JavaScript):

test('should throw error when withdrawing too much', () => {
    const account = new Account(100);

    expect(() => {
        account.withdraw(150);
    }).toThrow(InsufficientFundsError);

    expect(() => {
        account.withdraw(150);
    }).toThrow('Insufficient funds');
});

Property-Based Testing Pattern

Test properties that should always hold:

Example (Python with hypothesis):

from hypothesis import given
import hypothesis.strategies as st

@given(st.lists(st.integers()))
def test_reversing_twice_gives_original(lst):
    result = reverse(reverse(lst))
    assert result == lst

@given(st.integers(min_value=0))
def test_factorial_is_positive(n):
    result = factorial(n)
    assert result > 0

State-Based vs. Interaction-Based Testing

State-Based Testing (Preferred)

Verify final state rather than how it was achieved:

Example:

def test_should_remove_item_from_cart():
    cart = ShoppingCart()
    item = Item("Book", 29.99)
    cart.add_item(item)

    cart.remove_item(item)

    assert len(cart.items) == 0  # Verify state

Interaction-Based Testing

Verify interactions when state is not observable:

Example:

def test_should_log_failed_login_attempt():
    logger = Mock()
    auth = AuthService(logger)

    auth.login("user", "wrong_password")

    logger.warning.assert_called_once_with(
        "Failed login attempt for user: user"
    )

Test Naming Patterns

Should-Style Naming

test_should_<expected_behavior>_when_<condition>

Examples:

  • test_should_return_empty_list_when_no_matches_found
  • test_should_throw_exception_when_amount_is_negative
  • test_should_apply_discount_when_quantity_exceeds_ten

Behavior-Style Naming

test_<subject>_<scenario>_<expected_result>

Examples:

  • test_cart_with_multiple_items_calculates_correct_total
  • test_user_registration_with_invalid_email_fails
  • test_payment_processing_with_insufficient_funds_raises_error

Test Data Patterns

Obvious Data

Use self-documenting test data:

Bad:

def test_calculation():
    result = calculate(10, 5, 3)
    assert result == 8

Good:

def test_should_calculate_average_correctly():
    value1 = 10
    value2 = 20
    value3 = 30
    expected_average = 20

    result = calculate_average([value1, value2, value3])

    assert result == expected_average

Boundary Value Testing

Test edges of valid ranges:

def test_age_validation():
    validator = AgeValidator(min_age=18, max_age=100)

    # Below minimum
    assert not validator.is_valid(17)

    # At minimum boundary
    assert validator.is_valid(18)

    # Normal value
    assert validator.is_valid(50)

    # At maximum boundary
    assert validator.is_valid(100)

    # Above maximum
    assert not validator.is_valid(101)

TDD-Specific Patterns

Triangulation

Build generality through multiple test cases:

Step 1 - Specific case:

def test_should_add_two_numbers():
    assert add(2, 3) == 5

# Implementation:
def add(a, b):
    return 5  # Hardcoded - simplest thing that works

Step 2 - Add another case to force generalization:

def test_should_add_two_numbers():
    assert add(2, 3) == 5

def test_should_add_different_numbers():
    assert add(4, 7) == 11

# Implementation:
def add(a, b):
    return a + b  # Now forced to generalize

Fake It Till You Make It

Start with simple/hardcoded implementations:

# Test
def test_should_return_greeting():
    assert greet("World") == "Hello, World!"

# First implementation (fake it)
def greet(name):
    return "Hello, World!"

# Add another test to force real implementation
def test_should_return_greeting_with_different_name():
    assert greet("Alice") == "Hello, Alice!"

# Real implementation
def greet(name):
    return f"Hello, {name}!"

Obvious Implementation

When the implementation is obvious, just write it:

# Test
def test_should_calculate_rectangle_area():
    assert calculate_area(width=5, height=3) == 15

# Implementation (obvious, no need to fake)
def calculate_area(width, height):
    return width * height

Summary: Testing Pattern Selection

  • Use AAA for clarity in most tests
  • Use fixtures to eliminate setup duplication
  • Use builders for complex object creation
  • Use test doubles when dependencies are expensive or unpredictable
  • Prefer state-based testing over interaction-based
  • Use parameterized tests for multiple similar cases
  • Test boundaries explicitly
  • Name tests to describe behavior
  • Keep tests independent of each other
  • Make tests readable - they're documentation

Remember: Tests are first-class citizens in TDD. Treat them with the same care as production code.