You’ve inherited code without tests, and you need to add tests before making changes.

This guide walks you through adding tests to existing code step-by-step. You’ll learn to identify what to test first, write initial tests safely, and gradually improve coverage.

Before starting, read Fundamentals of Software Testing to understand testing principles.

Prerequisites

Before adding tests to existing code, ensure you have:

  • A codebase without tests - Or with insufficient test coverage
  • A testing framework installed - pytest (Python), Jest (JavaScript), JUnit (Java), etc.
  • Ability to run the code - You need to understand what the code currently does
  • Permission to make changes - Ensure you can modify the codebase

The Example: E-commerce Order Calculator

We’ll work with a real-world example: an order calculator that computes totals, applies discounts, and calculates tax.

The existing code (untested, production code):

# order_calculator.py
class OrderCalculator:
    def __init__(self):
        self.tax_rate = 0.08  # 8% tax
        self.discount_threshold = 100.00

    def calculate_total(self, items, customer_type):
        """Calculate order total with discounts and tax."""
        subtotal = 0

        # Sum item prices
        for item in items:
            subtotal += item['price'] * item['quantity']

        # Apply discount for large orders
        if subtotal >= self.discount_threshold:
            if customer_type == 'premium':
                subtotal = subtotal * 0.85  # 15% off
            elif customer_type == 'regular':
                subtotal = subtotal * 0.95  # 5% off

        # Add tax
        tax = subtotal * self.tax_rate
        total = subtotal + tax

        return total

    def apply_coupon(self, total, coupon_code):
        """Apply coupon code to total."""
        if coupon_code == 'SAVE10':
            return total - 10.00
        elif coupon_code == 'SAVE20':
            return total - 20.00
        else:
            return total

    def calculate_shipping(self, total, shipping_method):
        """Calculate shipping cost."""
        if shipping_method == 'express':
            return 20.00
        elif shipping_method == 'standard':
            if total > 50:
                return 0  # Free shipping over $50
            else:
                return 5.00
        else:
            return 0

This code works in production, but has no tests. Let’s add them systematically.

Step 1: Understand Current Behavior

Before adding tests, run the code to understand what it currently does.

Create a simple script to exercise the code:

# manual_test.py
from order_calculator import OrderCalculator

calc = OrderCalculator()

# Test case 1: Regular customer, small order
items = [
    {'price': 20.00, 'quantity': 2},  # $40
    {'price': 15.00, 'quantity': 1},  # $15
]
total = calc.calculate_total(items, 'regular')
print(f"Small order, regular customer: ${total:.2f}")

# Test case 2: Premium customer, large order
items = [
    {'price': 50.00, 'quantity': 3},  # $150
]
total = calc.calculate_total(items, 'premium')
print(f"Large order, premium customer: ${total:.2f}")

# Test case 3: Apply coupon
total_with_coupon = calc.apply_coupon(100.00, 'SAVE10')
print(f"After SAVE10 coupon: ${total_with_coupon:.2f}")

# Test case 4: Shipping
shipping_express = calc.calculate_shipping(100.00, 'express')
shipping_standard = calc.calculate_shipping(60.00, 'standard')
print(f"Express shipping: ${shipping_express:.2f}")
print(f"Standard shipping (over $50): ${shipping_standard:.2f}")

Run the manual test:

python manual_test.py

Expected output:

Small order, regular customer: $59.40
Large order, premium customer: $137.70
After SAVE10 coupon: $90.00
Express shipping: $20.00
Standard shipping (over $50): $0.00

Why this matters: Understanding current behavior ensures your tests verify what the code actually does, not what you think it should do.

Step 2: Identify What to Test First

Don’t try to test everything at once. Prioritize based on risk and value.

Ask yourself:

  1. What’s most critical? - Code that handles money, security, or user data
  2. What’s most complex? - Functions with multiple branches and edge cases
  3. What changes most often? - Code you’re about to modify
  4. What breaks most often? - Code with known bugs

For our example, prioritize:

  1. calculate_total - Handles money, complex logic, critical
  2. apply_coupon - Handles money, moderate complexity
  3. calculate_shipping - Important but simpler

Start with calculate_total because it’s the most critical and complex.

Step 3: Write Your First Test

Start with the simplest happy path test.

Create test file:

# test_order_calculator.py
import pytest
from order_calculator import OrderCalculator

def test_calculate_total_for_small_order_regular_customer():
    """Test that small order total is calculated correctly for regular customer."""
    # Arrange
    calc = OrderCalculator()
    items = [
        {'price': 20.00, 'quantity': 2},  # $40
        {'price': 15.00, 'quantity': 1},  # $15
    ]
    # Subtotal: $55 (no discount, below threshold)
    # Tax: $55 * 0.08 = $4.40
    # Total: $59.40

    # Act
    total = calc.calculate_total(items, 'regular')

    # Assert
    assert total == 59.40

Run the test:

pytest test_order_calculator.py -v

Expected output:

PASSED test_order_calculator.py::test_calculate_total_for_small_order_regular_customer

Congratulations! You’ve written your first test for existing code.

Step 4: Add More Test Cases Systematically

Now add tests for different scenarios, one at a time.

Test 2: Large Order with Discount

def test_calculate_total_for_large_order_premium_customer():
    """Test that large order gets premium discount and tax applied."""
    # Arrange
    calc = OrderCalculator()
    items = [
        {'price': 50.00, 'quantity': 3},  # $150
    ]
    # Subtotal: $150
    # After 15% discount: $150 * 0.85 = $127.50
    # Tax: $127.50 * 0.08 = $10.20
    # Total: $137.70

    # Act
    total = calc.calculate_total(items, 'premium')

    # Assert
    assert total == 137.70

Run tests:

pytest test_order_calculator.py -v

Expected output:

PASSED test_order_calculator.py::test_calculate_total_for_small_order_regular_customer
PASSED test_order_calculator.py::test_calculate_total_for_large_order_premium_customer

Test 3: Edge Case - Empty Order

def test_calculate_total_for_empty_order():
    """Test that empty order returns zero."""
    # Arrange
    calc = OrderCalculator()
    items = []

    # Act
    total = calc.calculate_total(items, 'regular')

    # Assert
    assert total == 0.00

Run the test:

pytest test_order_calculator.py::test_calculate_total_for_empty_order -v

Expected output:

PASSED test_order_calculator.py::test_calculate_total_for_empty_order

Test 4: Boundary Condition - Exactly at Threshold

def test_calculate_total_at_discount_threshold():
    """Test that order exactly at threshold gets discount."""
    # Arrange
    calc = OrderCalculator()
    items = [
        {'price': 100.00, 'quantity': 1},  # Exactly $100
    ]
    # Subtotal: $100
    # After 5% discount (regular): $100 * 0.95 = $95
    # Tax: $95 * 0.08 = $7.60
    # Total: $102.60

    # Act
    total = calc.calculate_total(items, 'regular')

    # Assert
    assert total == 102.60

Test 5: Boundary Condition - Just Below Threshold

def test_calculate_total_below_discount_threshold():
    """Test that order just below threshold gets no discount."""
    # Arrange
    calc = OrderCalculator()
    items = [
        {'price': 99.99, 'quantity': 1},  # Just under $100
    ]
    # Subtotal: $99.99 (no discount)
    # Tax: $99.99 * 0.08 = $7.9992, rounded to $7.999
    # Total: $107.99 (approximately)

    # Act
    total = calc.calculate_total(items, 'regular')

    # Assert
    assert total == pytest.approx(107.99, rel=0.01)  # Allow small rounding difference

Run all tests:

pytest test_order_calculator.py -v

Expected output:

PASSED test_order_calculator.py::test_calculate_total_for_small_order_regular_customer
PASSED test_order_calculator.py::test_calculate_total_for_large_order_premium_customer
PASSED test_order_calculator.py::test_calculate_total_for_empty_order
PASSED test_order_calculator.py::test_calculate_total_at_discount_threshold
PASSED test_order_calculator.py::test_calculate_total_below_discount_threshold

Step 5: Test Other Functions

Now that calculate_total has solid coverage, test the other functions.

Testing apply_coupon

def test_apply_coupon_save10():
    """Test that SAVE10 coupon subtracts $10."""
    # Arrange
    calc = OrderCalculator()
    total = 100.00

    # Act
    discounted = calc.apply_coupon(total, 'SAVE10')

    # Assert
    assert discounted == 90.00

def test_apply_coupon_save20():
    """Test that SAVE20 coupon subtracts $20."""
    calc = OrderCalculator()
    total = 100.00
    discounted = calc.apply_coupon(total, 'SAVE20')
    assert discounted == 80.00

def test_apply_coupon_invalid_code():
    """Test that invalid coupon code doesn't change total."""
    calc = OrderCalculator()
    total = 100.00
    discounted = calc.apply_coupon(total, 'INVALID')
    assert discounted == 100.00

Testing calculate_shipping

def test_calculate_shipping_express():
    """Test that express shipping costs $20."""
    calc = OrderCalculator()
    shipping = calc.calculate_shipping(100.00, 'express')
    assert shipping == 20.00

def test_calculate_shipping_standard_over_threshold():
    """Test that standard shipping is free over $50."""
    calc = OrderCalculator()
    shipping = calc.calculate_shipping(60.00, 'standard')
    assert shipping == 0.00

def test_calculate_shipping_standard_under_threshold():
    """Test that standard shipping costs $5 under $50."""
    calc = OrderCalculator()
    shipping = calc.calculate_shipping(40.00, 'standard')
    assert shipping == 5.00

def test_calculate_shipping_unknown_method():
    """Test that unknown shipping method returns $0."""
    calc = OrderCalculator()
    shipping = calc.calculate_shipping(100.00, 'unknown')
    assert shipping == 0.00

Run all tests:

pytest test_order_calculator.py -v

You should now have 13 passing tests!

Step 6: Check Coverage

See what code is tested and what’s not.

Install coverage tool:

pip install pytest-cov

Run tests with coverage:

pytest test_order_calculator.py --cov=order_calculator --cov-report=term-missing

Expected output:

---------- coverage: platform linux, python 3.x -----------
Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
order_calculator.py        30      0   100%
-----------------------------------------------------
TOTAL                      30      0   100%

Congratulations! You’ve achieved 100% coverage of the OrderCalculator class.

Step 7: Find and Fix a Bug with Tests

Now that you have tests, you can safely find and fix bugs.

Discovered bug: What happens if someone passes a negative quantity?

def test_calculate_total_with_negative_quantity():
    """Test that negative quantity is handled."""
    calc = OrderCalculator()
    items = [
        {'price': 20.00, 'quantity': -1},  # Negative quantity
    ]

    # Current behavior: allows negative total (bug!)
    total = calc.calculate_total(items, 'regular')

    # This test will fail because code doesn't validate
    assert total > 0  # We expect an error or zero, not negative

Run the test:

pytest test_order_calculator.py::test_calculate_total_with_negative_quantity -v

Expected output:

FAILED test_order_calculator.py::test_calculate_total_with_negative_quantity
AssertionError: assert -21.6 > 0

The test caught a bug! The code allows negative quantities, resulting in negative totals.

Fix the bug:

# order_calculator.py
def calculate_total(self, items, customer_type):
    """Calculate order total with discounts and tax."""
    subtotal = 0

    # Sum item prices
    for item in items:
        # Validate quantity
        if item['quantity'] < 0:
            raise ValueError(f"Quantity cannot be negative: {item['quantity']}")

        subtotal += item['price'] * item['quantity']

    # ... rest of function

Update the test to expect the error:

def test_calculate_total_with_negative_quantity_raises_error():
    """Test that negative quantity raises ValueError."""
    calc = OrderCalculator()
    items = [
        {'price': 20.00, 'quantity': -1},
    ]

    with pytest.raises(ValueError, match="Quantity cannot be negative"):
        calc.calculate_total(items, 'regular')

Run the test:

pytest test_order_calculator.py::test_calculate_total_with_negative_quantity_raises_error -v

Expected output:

PASSED test_order_calculator.py::test_calculate_total_with_negative_quantity_raises_error

The test now passes, and the bug is fixed!

Step 8: Refactor with Confidence

Now that you have tests, you can safely refactor the code.

Before refactoring:

def calculate_total(self, items, customer_type):
    """Calculate order total with discounts and tax."""
    subtotal = 0

    for item in items:
        if item['quantity'] < 0:
            raise ValueError(f"Quantity cannot be negative: {item['quantity']}")
        subtotal += item['price'] * item['quantity']

    if subtotal >= self.discount_threshold:
        if customer_type == 'premium':
            subtotal = subtotal * 0.85
        elif customer_type == 'regular':
            subtotal = subtotal * 0.95

    tax = subtotal * self.tax_rate
    total = subtotal + tax

    return total

After refactoring (extracting methods for clarity):

def _calculate_subtotal(self, items):
    """Calculate subtotal from items."""
    subtotal = 0
    for item in items:
        if item['quantity'] < 0:
            raise ValueError(f"Quantity cannot be negative: {item['quantity']}")
        subtotal += item['price'] * item['quantity']
    return subtotal

def _apply_discount(self, subtotal, customer_type):
    """Apply customer discount to subtotal."""
    if subtotal < self.discount_threshold:
        return subtotal

    if customer_type == 'premium':
        return subtotal * 0.85  # 15% off
    elif customer_type == 'regular':
        return subtotal * 0.95  # 5% off
    else:
        return subtotal

def calculate_total(self, items, customer_type):
    """Calculate order total with discounts and tax."""
    subtotal = self._calculate_subtotal(items)
    discounted = self._apply_discount(subtotal, customer_type)
    tax = discounted * self.tax_rate
    return discounted + tax

Run all tests to verify refactoring didn’t break anything:

pytest test_order_calculator.py -v

Expected output:

All tests pass!

The refactoring is safe because tests verify behavior is unchanged.

Step 9: Maintain Tests as Code Evolves

As you add features, add tests at the same time.

New requirement: Add a “bulk” customer type with 20% discount.

Write the test first:

def test_calculate_total_for_bulk_customer():
    """Test that bulk customers get 20% discount."""
    calc = OrderCalculator()
    items = [
        {'price': 100.00, 'quantity': 2},  # $200
    ]
    # Subtotal: $200
    # After 20% discount: $200 * 0.80 = $160
    # Tax: $160 * 0.08 = $12.80
    # Total: $172.80

    total = calc.calculate_total(items, 'bulk')
    assert total == 172.80

Run the test (it will fail):

pytest test_order_calculator.py::test_calculate_total_for_bulk_customer -v

Now implement the feature:

def _apply_discount(self, subtotal, customer_type):
    """Apply customer discount to subtotal."""
    if subtotal < self.discount_threshold:
        return subtotal

    if customer_type == 'premium':
        return subtotal * 0.85  # 15% off
    elif customer_type == 'bulk':
        return subtotal * 0.80  # 20% off
    elif customer_type == 'regular':
        return subtotal * 0.95  # 5% off
    else:
        return subtotal

Run the test (it should pass now):

pytest test_order_calculator.py::test_calculate_total_for_bulk_customer -v

You’ve successfully added a feature with tests!

Common Challenges and Solutions

Challenge 1: Code is Hard to Test

Problem: Functions have many dependencies, making tests difficult.

Solution: Use dependency injection and mocks.

Before:

def process_order(order_id):
    db = connect_to_database()  # Hard to test
    order = db.get_order(order_id)
    # ...

After:

def process_order(order_id, db):  # Dependency injected
    order = db.get_order(order_id)
    # ...

Test:

def test_process_order():
    mock_db = Mock()
    mock_db.get_order.return_value = {'id': 1, 'items': []}
    process_order(1, mock_db)
    mock_db.get_order.assert_called_once_with(1)

Challenge 2: Can’t Change Production Code

Problem: Code is in production, and you can’t risk breaking it.

Solution: Characterization tests - tests that document current behavior, even if it’s wrong.

def test_current_behavior_may_be_wrong():
    """Document current behavior, even if it seems wrong."""
    # TODO: This behavior seems incorrect, but documenting it
    # so we can safely refactor later
    result = legacy_function(weird_input)
    assert result == weird_output  # Whatever it currently returns

Challenge 3: No Idea What Code Does

Problem: Code is complex, and you don’t understand it.

Solution: Write exploratory tests to learn.

def test_explore_function_behavior():
    """Explore what this function actually does."""
    # Try different inputs and see what happens
    result1 = mystery_function(0)
    result2 = mystery_function(1)
    result3 = mystery_function(-1)

    # Document what you learned
    assert result1 == expected1  # Fill in after running
    assert result2 == expected2
    assert result3 == expected3

Challenge 4: Tests Are Too Slow

Problem: Tests take too long because they hit databases or external services.

Solution: Use mocks and test doubles.

Before:

def test_user_creation():
    db = RealDatabase()  # Slow
    user = create_user('test@example.com', db)
    assert user.email == 'test@example.com'

After:

def test_user_creation():
    mock_db = Mock()  # Fast
    user = create_user('test@example.com', mock_db)
    assert user.email == 'test@example.com'
    mock_db.save.assert_called_once()

Next Steps

You’ve successfully added tests to existing code! Here’s what to do next:

  1. Continue adding tests - Work through the rest of your codebase, prioritizing critical code
  2. Set up continuous integration - Run tests automatically on every commit
  3. Establish coverage goals - Aim for high coverage of critical paths
  4. Review testing practices - Use the Reference: Testing Checklist
  5. Learn advanced techniques - Study mocking, fixtures, and test organization

Summary

You’ve learned to:

  • ✅ Understand existing code behavior before testing
  • ✅ Identify what to test first (critical, complex, changing code)
  • ✅ Write your first test for existing code
  • ✅ Add tests systematically (happy path, edge cases, errors)
  • ✅ Check test coverage
  • ✅ Find and fix bugs with tests
  • ✅ Refactor safely with test protection
  • ✅ Maintain tests as code evolves

Key Takeaways

When adding tests to existing code:

  1. Start small - One function, one test at a time
  2. Prioritize - Test critical code first
  3. Document current behavior - Tests show what code does today
  4. Find bugs safely - Tests catch regressions as you improve code
  5. Refactor confidently - Tests verify behavior is preserved

Remember: You don’t need 100% coverage immediately. Start with critical paths and grow coverage over time.

References

You now have a systematic approach to adding tests to existing code. Start with one function, write one test, and build from there.