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]({{< ref "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):
```python
# 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:**
```python
# 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:**
```bash
python manual_test.py
```
**Expected output:**
```text
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:**
```python
# 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:**
```bash
pytest test_order_calculator.py -v
```
**Expected output:**
```text
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
```python
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:**
```bash
pytest test_order_calculator.py -v
```
**Expected output:**
```text
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
```python
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:**
```bash
pytest test_order_calculator.py::test_calculate_total_for_empty_order -v
```
**Expected output:**
```text
PASSED test_order_calculator.py::test_calculate_total_for_empty_order
```
### Test 4: Boundary Condition - Exactly at Threshold
```python
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
```python
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:**
```bash
pytest test_order_calculator.py -v
```
**Expected output:**
```text
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`
```python
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`
```python
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:**
```bash
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:**
```bash
pip install pytest-cov
```
**Run tests with coverage:**
```bash
pytest test_order_calculator.py --cov=order_calculator --cov-report=term-missing
```
**Expected output:**
```text
---------- 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?
```python
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:**
```bash
pytest test_order_calculator.py::test_calculate_total_with_negative_quantity -v
```
**Expected output:**
```text
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:**
```python
# 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:**
```python
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:**
```bash
pytest test_order_calculator.py::test_calculate_total_with_negative_quantity_raises_error -v
```
**Expected output:**
```text
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:**
```python
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):
```python
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:**
```bash
pytest test_order_calculator.py -v
```
**Expected output:**
```text
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:**
```python
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):**
```bash
pytest test_order_calculator.py::test_calculate_total_for_bulk_customer -v
```
**Now implement the feature:**
```python
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):**
```bash
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:**
```python
def process_order(order_id):
db = connect_to_database() # Hard to test
order = db.get_order(order_id)
# ...
```
**After:**
```python
def process_order(order_id, db): # Dependency injected
order = db.get_order(order_id)
# ...
```
**Test:**
```python
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.
```python
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.
```python
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:**
```python
def test_user_creation():
db = RealDatabase() # Slow
user = create_user('test@example.com', db)
assert user.email == 'test@example.com'
```
**After:**
```python
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]({{< ref "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
* [Fundamentals of Software Testing]({{< ref "fundamentals-of-software-testing" >}}) - Testing principles and concepts
* [How to Write Effective Unit Tests]({{< ref "how-to-write-effective-unit-tests" >}}) - Step-by-step unit testing workflow
* [Reference: Testing Checklist]({{< ref "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.