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 0This 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.pyExpected 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.00Why 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:
- What’s most critical? - Code that handles money, security, or user data
- What’s most complex? - Functions with multiple branches and edge cases
- What changes most often? - Code you’re about to modify
- What breaks most often? - Code with known bugs
For our example, prioritize:
- ✅
calculate_total- Handles money, complex logic, critical - ✅
apply_coupon- Handles money, moderate complexity - ✅
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.40Run the test:
pytest test_order_calculator.py -vExpected output:
PASSED test_order_calculator.py::test_calculate_total_for_small_order_regular_customerCongratulations! 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.70Run tests:
pytest test_order_calculator.py -vExpected 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_customerTest 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.00Run the test:
pytest test_order_calculator.py::test_calculate_total_for_empty_order -vExpected output:
PASSED test_order_calculator.py::test_calculate_total_for_empty_orderTest 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.60Test 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 differenceRun all tests:
pytest test_order_calculator.py -vExpected 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_thresholdStep 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.00Testing 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.00Run all tests:
pytest test_order_calculator.py -vYou 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-covRun tests with coverage:
pytest test_order_calculator.py --cov=order_calculator --cov-report=term-missingExpected 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 negativeRun the test:
pytest test_order_calculator.py::test_calculate_total_with_negative_quantity -vExpected output:
FAILED test_order_calculator.py::test_calculate_total_with_negative_quantity
AssertionError: assert -21.6 > 0The 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 functionUpdate 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 -vExpected output:
PASSED test_order_calculator.py::test_calculate_total_with_negative_quantity_raises_errorThe 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 totalAfter 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 + taxRun all tests to verify refactoring didn’t break anything:
pytest test_order_calculator.py -vExpected 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.80Run the test (it will fail):
pytest test_order_calculator.py::test_calculate_total_for_bulk_customer -vNow 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 subtotalRun the test (it should pass now):
pytest test_order_calculator.py::test_calculate_total_for_bulk_customer -vYou’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 returnsChallenge 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 == expected3Challenge 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:
- Continue adding tests - Work through the rest of your codebase, prioritizing critical code
- Set up continuous integration - Run tests automatically on every commit
- Establish coverage goals - Aim for high coverage of critical paths
- Review testing practices - Use the Reference: Testing Checklist
- 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:
- Start small - One function, one test at a time
- Prioritize - Test critical code first
- Document current behavior - Tests show what code does today
- Find bugs safely - Tests catch regressions as you improve code
- 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
- Fundamentals of Software Testing - Testing principles and concepts
- How to Write Effective Unit Tests - Step-by-step unit testing workflow
- Reference: Testing Checklist - Comprehensive testing checklist
- Michael Feathers’ “Working Effectively with Legacy Code” - The definitive guide to adding tests to existing code
- Martin Fowler’s “Refactoring” - Techniques for improving code with test protection
You now have a systematic approach to adding tests to existing code. Start with one function, write one test, and build from there.

Comments #