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](https://jeffbailey.us/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](https://jeffbailey.us/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](https://jeffbailey.us/fundamentals-of-software-testing/) - Testing principles and concepts * [How to Write Effective Unit Tests](/blog/2025/11/30/how-to-write-effective-unit-tests/) - Step-by-step unit testing workflow * [Reference: Testing Checklist](https://jeffbailey.us/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.