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

15 KiB

Code Smells That Indicate Test-After Development

This document catalogs code smells and anti-patterns that strongly suggest tests were written after implementation rather than following TDD methodology.

Understanding Code Smells in TDD Context

When developers write tests after code (test-after), they tend to produce different code structures than when following TDD. This is because:

  1. TDD enforces small steps: Each test drives minimal implementation
  2. TDD encourages refactoring: The refactor phase continuously improves structure
  3. TDD requires testability: Code must be designed for easy testing from the start
  4. TDD prevents over-engineering: Only write code needed to pass tests

Test-after code often shows signs of:

  • Solving problems that don't exist yet (premature optimization)
  • Complex structures built all at once (big bang implementation)
  • Difficult-to-test designs (retrofitted testability)
  • Accumulated technical debt (skipped refactoring)

High-Severity Code Smells

1. Deeply Nested Conditionals

Description: Multiple levels of if/elif/else statements nested within each other.

Why it indicates test-after:

  • TDD would break this down into separate, testable functions
  • Each branch would have its own test, encouraging extraction
  • Refactor phase would identify and eliminate deep nesting

Example (Bad):

def process_order(order):
    if order.customer:
        if order.customer.is_active:
            if order.items:
                if order.total > 0:
                    if order.payment_method:
                        if order.payment_method == "credit_card":
                            if order.customer.credit_limit >= order.total:
                                # Process credit card payment
                                return "processed"
                            else:
                                return "insufficient_credit"
                        else:
                            # Process other payment
                            return "processed"
                    else:
                        return "no_payment_method"
                else:
                    return "invalid_total"
            else:
                return "no_items"
        else:
            return "inactive_customer"
    else:
        return "no_customer"

TDD Alternative:

def process_order(order):
    _validate_order(order)
    _validate_customer(order.customer)
    _validate_payment(order)
    return _execute_payment(order)

def _validate_order(order):
    if not order.items:
        raise OrderValidationError("Order must have items")
    if order.total <= 0:
        raise OrderValidationError("Order total must be positive")

def _validate_customer(customer):
    if not customer:
        raise OrderValidationError("Order must have a customer")
    if not customer.is_active:
        raise OrderValidationError("Customer is inactive")

def _validate_payment(order):
    if not order.payment_method:
        raise OrderValidationError("Payment method required")

def _execute_payment(order):
    payment_processor = PaymentProcessorFactory.create(order.payment_method)
    return payment_processor.process(order)

How to detect: Look for 3+ levels of nested if/else statements.

2. Long Methods/Functions

Description: Methods exceeding 20-30 lines of code.

Why it indicates test-after:

  • TDD naturally produces small functions (5-15 lines)
  • Each test typically drives one small piece of functionality
  • Long methods suggest big-bang implementation

Example (Bad):

def generate_invoice(order_id):
    # 80+ lines of mixed responsibilities:
    # - Database queries
    # - Business logic
    # - Calculations
    # - Formatting
    # - File generation
    # - Email sending
    order = db.query(Order).filter_by(id=order_id).first()
    if not order:
        return None

    total = 0
    for item in order.items:
        if item.discount:
            price = item.price * (1 - item.discount)
        else:
            price = item.price
        total += price * item.quantity

    tax = total * 0.1
    shipping = 10 if total < 50 else 0
    grand_total = total + tax + shipping

    # ... 50 more lines of formatting and sending

TDD Alternative:

def generate_invoice(order_id):
    order = _fetch_order(order_id)
    invoice_data = _calculate_invoice_totals(order)
    formatted_invoice = _format_invoice(order, invoice_data)
    _send_invoice(order.customer.email, formatted_invoice)
    return formatted_invoice

def _fetch_order(order_id):
    order = db.query(Order).filter_by(id=order_id).first()
    if not order:
        raise OrderNotFoundError(f"Order {order_id} not found")
    return order

def _calculate_invoice_totals(order):
    subtotal = sum(_calculate_line_total(item) for item in order.items)
    tax = _calculate_tax(subtotal)
    shipping = _calculate_shipping(subtotal)
    return InvoiceTotals(subtotal, tax, shipping)

def _calculate_line_total(item):
    price = item.price * (1 - item.discount) if item.discount else item.price
    return price * item.quantity

How to detect: Count lines in methods. Flag anything over 20 lines.

3. Complex Boolean Conditions

Description: Conditional expressions with multiple AND/OR operators.

Why it indicates test-after:

  • TDD encourages extracting complex conditions into named methods
  • Each condition part would have its own test
  • Refactor phase would identify complexity and extract it

Example (Bad):

if (user.age >= 18 and user.has_license and
    user.years_experience >= 2 and
    (user.state == "CA" or user.state == "NY") and
    not user.has_violations and user.insurance_valid):
    # Allow to rent car
    pass

TDD Alternative:

def can_rent_car(user):
    return (is_eligible_driver(user) and
            is_in_service_area(user) and
            has_clean_record(user))

def is_eligible_driver(user):
    return user.age >= 18 and user.has_license and user.years_experience >= 2

def is_in_service_area(user):
    return user.state in ["CA", "NY"]

def has_clean_record(user):
    return not user.has_violations and user.insurance_valid

How to detect: Count AND/OR operators. Flag conditions with 3+ logical operators.

4. God Objects/Classes

Description: Classes with too many responsibilities and methods.

Why it indicates test-after:

  • TDD enforces Single Responsibility Principle through testing
  • Each test focuses on one behavior, encouraging focused classes
  • Testing god objects is painful, encouraging decomposition

Example (Bad):

class UserManager:
    def authenticate(self, username, password): pass
    def create_user(self, user_data): pass
    def update_user(self, user_id, data): pass
    def delete_user(self, user_id): pass
    def send_welcome_email(self, user): pass
    def send_password_reset(self, user): pass
    def validate_email(self, email): pass
    def validate_password(self, password): pass
    def log_user_activity(self, user, action): pass
    def generate_report(self, user_id): pass
    def export_user_data(self, user_id): pass
    def import_users(self, file_path): pass
    # ... 20 more methods

TDD Alternative:

class AuthenticationService:
    def authenticate(self, username, password): pass

class UserRepository:
    def create(self, user): pass
    def update(self, user_id, data): pass
    def delete(self, user_id): pass
    def find_by_id(self, user_id): pass

class EmailService:
    def send_welcome_email(self, user): pass
    def send_password_reset(self, user): pass

class UserValidator:
    def validate_email(self, email): pass
    def validate_password(self, password): pass

class UserReportGenerator:
    def generate_report(self, user_id): pass

How to detect: Count methods in class. Flag classes with 10+ methods.

Medium-Severity Code Smells

5. Type Checking Instead of Polymorphism

Description: Using isinstance(), typeof, or type switches instead of polymorphic design.

Why it indicates test-after:

  • TDD encourages interface-based design through mocking
  • Polymorphism emerges naturally when testing behaviors
  • Type checking makes testing harder, encouraging better design

Example (Bad):

def calculate_area(shape):
    if isinstance(shape, Circle):
        return 3.14159 * shape.radius ** 2
    elif isinstance(shape, Rectangle):
        return shape.width * shape.height
    elif isinstance(shape, Triangle):
        return 0.5 * shape.base * shape.height
    else:
        raise ValueError("Unknown shape type")

TDD Alternative:

class Shape(ABC):
    @abstractmethod
    def calculate_area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def calculate_area(self):
        return 3.14159 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

# Usage:
def process_shape(shape: Shape):
    return shape.calculate_area()

How to detect: Search for isinstance(), typeof, or type switch patterns.

6. Duplicate Code Blocks

Description: Same or similar code repeated in multiple places.

Why it indicates test-after:

  • TDD's refactor phase explicitly targets duplication
  • Each cycle includes time to eliminate redundancy
  • Test-after often skips refactoring altogether

Example (Bad):

def calculate_discount_price_for_books(price, quantity):
    if quantity >= 10:
        discount = 0.2
    elif quantity >= 5:
        discount = 0.1
    else:
        discount = 0
    return price * (1 - discount)

def calculate_discount_price_for_electronics(price, quantity):
    if quantity >= 10:
        discount = 0.15
    elif quantity >= 5:
        discount = 0.08
    else:
        discount = 0
    return price * (1 - discount)

TDD Alternative:

def calculate_discount_price(price, quantity, discount_tiers):
    discount = _get_discount_for_quantity(quantity, discount_tiers)
    return price * (1 - discount)

def _get_discount_for_quantity(quantity, tiers):
    for min_qty, discount in sorted(tiers.items(), reverse=True):
        if quantity >= min_qty:
            return discount
    return 0

# Usage:
BOOK_DISCOUNTS = {10: 0.2, 5: 0.1}
ELECTRONICS_DISCOUNTS = {10: 0.15, 5: 0.08}

book_price = calculate_discount_price(29.99, 12, BOOK_DISCOUNTS)

How to detect: Use code duplication analysis tools (>6 lines duplicated).

7. Primitive Obsession

Description: Using primitive types instead of small objects to represent concepts.

Why it indicates test-after:

  • TDD encourages creating types that make tests clearer
  • Value objects emerge naturally when expressing test intent
  • Primitives make tests verbose and unclear

Example (Bad):

def create_appointment(patient_id, doctor_id, date_str, time_str, duration_mins):
    # Working with primitives throughout
    date = datetime.strptime(date_str, "%Y-%m-%d")
    time = datetime.strptime(time_str, "%H:%M")
    # ... complex validation and manipulation

TDD Alternative:

@dataclass
class AppointmentTime:
    date: datetime.date
    time: datetime.time
    duration: timedelta

    def __post_init__(self):
        if self.duration <= timedelta(0):
            raise ValueError("Duration must be positive")

    def end_time(self):
        start = datetime.combine(self.date, self.time)
        return start + self.duration

def create_appointment(patient_id, doctor_id, appointment_time: AppointmentTime):
    # Working with rich domain objects
    pass

How to detect: Look for functions with many primitive parameters (4+).

8. Comments Explaining What Code Does

Description: Comments that explain the mechanics of the code rather than the "why".

Why it indicates test-after:

  • TDD produces self-documenting code through clear naming
  • Tests serve as documentation for behavior
  • Need for "what" comments suggests unclear code

Example (Bad):

def process(data):
    # Loop through each item in data
    for item in data:
        # Check if item value is greater than 100
        if item.value > 100:
            # Multiply value by 1.5
            item.value = item.value * 1.5
        # Check if item is active
        if item.is_active:
            # Add item to results list
            results.append(item)

TDD Alternative:

def process_high_value_active_items(items):
    return [apply_premium_pricing(item)
            for item in items
            if is_premium_eligible(item)]

def is_premium_eligible(item):
    return item.value > 100 and item.is_active

def apply_premium_pricing(item):
    item.value *= PREMIUM_MULTIPLIER
    return item

How to detect: Look for comments explaining mechanics; good comments explain "why".

Low-Severity Code Smells

9. Magic Numbers

Description: Unexplained numeric literals scattered throughout code.

Example (Bad):

def calculate_shipping(weight):
    if weight < 5:
        return 10
    elif weight < 20:
        return 25
    else:
        return 50

TDD Alternative:

LIGHT_PACKAGE_THRESHOLD = 5
MEDIUM_PACKAGE_THRESHOLD = 20
LIGHT_PACKAGE_RATE = 10
MEDIUM_PACKAGE_RATE = 25
HEAVY_PACKAGE_RATE = 50

def calculate_shipping(weight):
    if weight < LIGHT_PACKAGE_THRESHOLD:
        return LIGHT_PACKAGE_RATE
    elif weight < MEDIUM_PACKAGE_THRESHOLD:
        return MEDIUM_PACKAGE_RATE
    else:
        return HEAVY_PACKAGE_RATE

10. Long Parameter Lists

Description: Methods accepting many parameters (4+).

Example (Bad):

def create_user(first_name, last_name, email, phone, address, city, state, zip, country):
    pass

TDD Alternative:

@dataclass
class UserProfile:
    first_name: str
    last_name: str
    email: str
    phone: str

@dataclass
class Address:
    street: str
    city: str
    state: str
    zip: str
    country: str

def create_user(profile: UserProfile, address: Address):
    pass

Detection Strategy

Automated Checks

Run these checks regularly to identify test-after patterns:

  1. Cyclomatic Complexity: Flag methods with complexity > 10
  2. Method Length: Flag methods > 20 lines
  3. Class Size: Flag classes with > 10 methods
  4. Nesting Depth: Flag code with > 3 levels of nesting
  5. Duplication: Flag blocks of > 6 duplicated lines
  6. Parameter Count: Flag methods with > 4 parameters

Manual Review

Look for these patterns during code review:

  1. Large commits with code and tests together
  2. Tests that test implementation rather than behavior
  3. Absence of refactoring commits
  4. Complex code without corresponding complex tests
  5. Tests that mock internal methods

Refactoring from Test-After to TDD

If you inherit test-after code:

  1. Add characterization tests: Cover existing behavior
  2. Identify smells: Use automated and manual detection
  3. Extract methods: Break down large methods
  4. Introduce types: Replace primitives with value objects
  5. Apply patterns: Use polymorphism, strategy, etc.
  6. Write tests first for new features: Start TDD from now

Remember: The goal isn't perfect code, but continuously improving code quality through TDD discipline.