Skip to content

Why unit testing matters

Understanding the value and purpose of automated testing.

Contents

The problem: software complexity

Software starts simple. You write a function, run it a few times, see it works, and move on. But as your codebase grows, something changes.

How code breaks as it grows

Imagine you're building a calculator. First, you add an add() function. Easy. Then subtract(), multiply(), and divide(). Still manageable. But then:

  • You modify divide() to handle division by zero
  • You update add() to work with floating-point numbers
  • You refactor the code to use a Calculator class
  • You add new features that depend on these functions

Suddenly, a "small change" to add() might break something that depends on it. Without tests, you won't know until a user reports a bug – or worse, silently produces incorrect results.

The core problem: As software grows, the number of possible interactions grows exponentially, but human ability to track them doesn't.

The cost of bugs in production

Consider these scenarios:

  • E-commerce: A bug in the discount calculation overcharges customers. Result: refunds, customer service time, lost trust, and potential legal issues.
  • Healthcare: A medication dosage calculator has a rounding error. Result: patient harm and massive liability.
  • Finance: An interest calculation is wrong by 0.01%. Result: regulatory fines and customer compensation.
  • Infrastructure: A deployment script has a logic error. Result: system downtime costing thousands per hour.

Bugs found in production are expensive:

  • Customer service and support costs
  • Emergency hotfixes and rushed deployments
  • Damage to reputation and trust
  • Potential legal and regulatory consequences
  • Stressed developers working late to fix issues

Finding bugs earlier is dramatically cheaper. A bug caught in development costs minutes to fix. The same bug in production can cost thousands of pounds and weeks of work.

The difficulty of manual testing

Manual testing has fundamental limitations:

Time constraints: Manually testing every function after every change is impractical. On a codebase with 100 functions, testing each one after a small change could take hours.

Human error: People make mistakes. We forget edge cases, skip steps when tired, and struggle to test the same thing the same way every time.

Scalability: As your codebase grows, manual testing becomes impossible. A project with 1,000 functions and 50 developers making changes daily cannot rely on manual verification.

Confidence: Without automated tests, you never know for certain if your change broke something. You rely on hope and luck, which isn't a professional approach.

What unit testing provides

Unit tests are automated checks that verify individual pieces of code work correctly. Think of them as a safety net that catches you when you make mistakes.

Confidence: know your code works

With comprehensive tests, you know your code works – not because you think it does, but because the tests prove it. You can:

  • Make changes without fear of breaking things
  • Refactor code with confidence
  • Deploy to production knowing the fundamentals work
  • Sleep better at night
def calculate_discount(price, percentage):
    """Calculate discounted price."""
    if percentage > 100:
        return 0
    return price * (1 - percentage / 100)

# Test proves this function works correctly
def test_calculate_discount_with_valid_input(self):
    self.assertEqual(calculate_discount(100, 20), 80)

Documentation: tests describe expected behaviour

Tests are executable documentation. They show exactly how code should behave in specific situations:

def test_divide_by_zero_raises_error(self):
    """Division by zero should raise ZeroDivisionError."""
    with self.assertRaises(ZeroDivisionError):
        divide(10, 0)

This test tells you two things: 1. The divide() function should raise ZeroDivisionError for zero divisors 2. This behaviour is intentional, not a bug

Unlike comments, tests can't lie – if they pass, the behaviour matches the description.

Refactoring safety: change code without fear

Refactoring means improving code structure without changing behaviour. Tests make this safe.

Without tests:

# Original (messy but works)
def calculate_total(items):
    t = 0
    for i in items:
        t = t + i['price'] * i['qty']
    return t

# Refactored (cleaner, but does it work?)
def calculate_total(items):
    return sum(item['price'] * item['quantity'] for item in items)

Did you spot the bug? 'qty' became 'quantity'. Without tests, this might not be caught until production.

With tests:

def test_calculate_total_with_multiple_items(self):
    items = [
        {'price': 10, 'qty': 2},
        {'price': 5, 'qty': 3}
    ]
    self.assertEqual(calculate_total(items), 35)

Run the tests after refactoring. If they pass, you haven't broken anything. If they fail, you immediately know what broke.

Regression prevention: catch when old bugs return

A regression is when a bug you've already fixed reappears. This happens more often than you'd think:

  1. Bug is reported and fixed
  2. Months later, someone refactors the code
  3. The bug returns, unnoticed
  4. Users encounter the same problem again

The solution: When you fix a bug, write a test that fails without the fix and passes with it. This test forever prevents the regression:

def test_discount_never_exceeds_original_price(self):
    """Regression test for bug #123: 150% discount gave negative price."""
    result = calculate_discount(100, 150)
    self.assertGreaterEqual(result, 0)

Design feedback: tests reveal design issues

If code is hard to test, it's often poorly designed. Tests give you immediate feedback:

Hard to test? Probably needs improvement: - Function does too many things (lacks single responsibility) - Function depends on global state - Function has hidden dependencies - Logic is tangled and hard to isolate

Easy to test? Probably well-designed: - Function has clear inputs and outputs - Function is independent and focused - Logic is straightforward to reason about

Tests encourage better design by rewarding simplicity and clarity.

The testing pyramid

Not all tests are equal. The testing pyramid shows the ideal balance:

        /\
       /  \      E2E Tests (Few)
      /____\     - Test entire system
     /      \    - Slow, fragile, expensive
    /        \
   /   I N T  \  Integration Tests (Some)
  /____________\ - Test components together
 /              \
/   U  N  I  T   \ Unit Tests (Many)
/________________\ - Test individual functions
                   - Fast, reliable, cheap

Unit tests: the foundation

Characteristics: - Test one function or method in isolation - Run in milliseconds - Don't touch databases, networks, or file systems - Easy to write and maintain

Why many? You want to test every function with multiple scenarios (normal cases, edge cases, errors). This creates hundreds or thousands of unit tests.

Example:

def test_add_positive_numbers(self):
    self.assertEqual(add(2, 3), 5)

Integration tests: the middle layer

Characteristics: - Test multiple components working together - May touch databases or external services - Run in seconds - More complex to set up

Why fewer? They're slower and more brittle. Use them to verify components integrate correctly, not to test all possible scenarios.

Example: Testing that your web API correctly saves data to a database.

End-to-end tests: the tip

Characteristics: - Test the entire system from a user's perspective - Run through the UI or API - Run in minutes - Fragile and expensive to maintain

Why fewest? They're slow, brittle, and hard to debug. Use them only for critical user workflows.

Example: Testing that a user can sign up, log in, make a purchase, and receive a confirmation email.

Why unit tests form the foundation

Unit tests should be your primary testing strategy because they're:

  • Fast: Thousands of unit tests run in seconds
  • Reliable: No external dependencies means they rarely have false failures
  • Pinpointed: When they fail, you know exactly what broke
  • Cheap: Easy to write and maintain

A solid base of unit tests catches most bugs early, whilst a few integration and E2E tests verify the system works as a whole.

Return on investment

Writing tests takes time. Is it worth it?

Time saved debugging

Without tests: 1. User reports a bug 2. You can't reproduce it locally 3. You add logging and redeploy 4. You analyse logs to find the issue 5. You fix it and hope it works 6. Total time: 4 hours

With tests: 1. User reports a bug 2. You write a test that reproduces it (fails) 3. You fix the code (test passes) 4. You run all tests to ensure nothing else broke 5. Total time: 30 minutes

Tests turn debugging from archaeology into science.

Reduced production incidents

Every bug caught by tests is one that doesn't reach users:

  • No emergency hotfixes
  • No weekend deployments
  • No stressed developers
  • No angry users

Real example: A company added comprehensive tests to their payment processing code. Production incidents dropped from 12 per month to 2 per month. Cost savings: £50,000 per year in reduced support and development time.

Faster development cycles

It sounds counterintuitive, but tests make you faster:

Short-term (writing initial code): - Slightly slower (you're writing tests)

Long-term (maintaining and extending code): - Much faster (you can change code confidently) - No time wasted manually testing - No time wasted debugging production issues - No fear of touching old code

After the initial learning curve, teams with good test coverage ship features faster than teams without.

Improved code quality

Tests encourage: - Smaller, focused functions (easier to test) - Clear interfaces (tests show how code is used) - Less coupling (independent code is easier to test) - Better error handling (you test error cases)

The result: cleaner, more maintainable code.

When testing helps most

Not everything needs the same level of testing. Focus tests where they provide the most value:

Complex business logic

Mathematical calculations, algorithms, and business rules are perfect for testing:

# Lots of edge cases and special conditions
def calculate_tax(income, deductions, tax_year):
    """Complex tax calculation with many rules."""
    # This NEEDS comprehensive tests

Edge cases and error handling

Testing that your code handles unusual inputs correctly:

def test_empty_list_returns_zero(self):
    self.assertEqual(calculate_average([]), 0)

def test_negative_quantity_raises_error(self):
    with self.assertRaises(ValueError):
        calculate_cost(price=10, quantity=-1)

Code that will change over time

If you'll modify or extend code later, tests protect your changes:

# Today: Basic discount
def apply_discount(price, percentage):
    return price * (1 - percentage / 100)

# Later: Add volume discounts, promotional codes, etc.
# Tests ensure you don't break existing functionality

Mission-critical functionality

Code that absolutely must work deserves extra testing:

  • Payment processing
  • Security and authentication
  • Data integrity operations
  • User data handling

If a bug would be catastrophic, test thoroughly.

Common misconceptions

"Testing takes too much time"

The myth: Writing tests doubles development time.

The reality: Tests save time in the long run.

Initial development might be 20-30% slower, but maintenance and debugging become dramatically faster. Over a project's lifetime, you save far more time than you invest.

Trade-off: Yes, there's an upfront cost. But would you rather spend time writing tests or debugging production issues at 2 AM?

"100% coverage means bug-free"

The myth: If every line of code is tested, there are no bugs.

The reality: Coverage measures what code runs, not what's actually tested.

def divide(a, b):
    return a / b

def test_divide(self):
    result = divide(10, 2)  # 100% coverage!
    # But doesn't test division by zero

High coverage is good, but coverage alone doesn't guarantee correctness. You need tests that check the right things.

"Tests are just extra code to maintain"

The myth: Tests add maintenance burden without value.

The reality: Tests reduce maintenance burden.

Yes, tests are code. But they: - Catch bugs when you modify code - Document how code should behave - Enable confident refactoring - Pay for themselves many times over

Good tests make maintenance easier, not harder.

"Manual testing is enough"

The myth: If I test my changes manually, that's sufficient.

The reality: Manual testing doesn't scale and isn't repeatable.

What happens when: - Someone else changes your code? - You need to test the same thing 50 times? - You forget to test an edge case? - You have 100 functions to test?

Automated tests run consistently, exhaustively, and instantly. Manual testing can't compete.

Test-driven development

Test-Driven Development (TDD) is a practice where you write tests before code.

The Red-Green-Refactor cycle

  1. Red: Write a failing test for the functionality you want

    def test_calculate_discount(self):
        self.assertEqual(calculate_discount(100, 20), 80)  # Fails - function doesn't exist
    

  2. Green: Write the minimum code to make the test pass

    def calculate_discount(price, percentage):
        return price * (1 - percentage / 100)  # Test passes
    

  3. Refactor: Improve the code whilst keeping tests passing

    def calculate_discount(price, percentage):
        if percentage > 100:
            return 0
        return price * (1 - percentage / 100)  # Still passes, but cleaner
    

How TDD influences design

Writing tests first forces you to think about: - What the function should do before how it does it - The function's interface (what inputs and outputs make sense) - Edge cases and error conditions upfront

This leads to better-designed, more focused functions.

When TDD is most valuable

TDD works best for: - Well-understood problems with clear requirements - Complex algorithms that need careful thought - Code that will be maintained long-term - Learning and experimentation (tests document your discoveries)

TDD is less useful for: - Exploratory prototyping (requirements unclear) - Simple glue code (tests provide little value) - UI code (hard to test-first)

Learning more about TDD

TDD is a valuable practice, but it requires practice to master. For now, focus on writing tests alongside your code. As you become comfortable with testing, you can explore TDD further.

Future resource: Watch for our upcoming TDD tutorial (coming soon).

Real-world impact

The value of testing isn't theoretical – it's demonstrated by data and experience.

Case study: Ariane 5 rocket failure

In 1996, the Ariane 5 rocket exploded 37 seconds after launch due to a software error. The bug: a 64-bit floating-point number was converted to a 16-bit signed integer, causing an overflow.

Cost: £6 billion project destroyed.

Could tests have prevented this? Absolutely. A simple test checking the conversion with expected flight data would have caught the overflow immediately.

Lesson: Even critical, expensive systems can fail from simple, testable bugs.

Statistics on testing effectiveness

Research and industry experience show:

  • Defect reduction: Projects with comprehensive test suites have 40-80% fewer bugs in production
  • Developer productivity: After an initial learning period, developers with good tests work 15-30% faster
  • Maintenance costs: Well-tested code costs significantly less to maintain and modify
  • Confidence: Developers report higher confidence and job satisfaction when working with tested code

Developer experiences

Before tests: - "I'm afraid to change anything – it might break" - "I spend most of my time debugging" - "Every deployment is stressful" - "I can't remember if this edge case is handled"

After tests: - "I can refactor confidently" - "Tests catch bugs immediately" - "Deployments are routine" - "The tests document exactly what works"

Industry adoption

Testing is standard practice in professional software development:

  • Google: Requires tests for almost all code changes
  • Microsoft: Extensive automated testing for Windows and Office
  • Amazon: Tests are critical for their scale and reliability
  • Startups: Even small teams adopt testing to move fast without breaking things

If the world's best software companies rely on automated testing, there's a reason.

Getting started

Convinced that testing matters? Here's how to begin:

Start small

You don't need to test everything immediately. Begin with:

  1. One function: Pick a function and write 3-5 tests for it
  2. New code: Write tests for new functions you create
  3. Bug fixes: When you fix a bug, add a test that prevents it returning

Over time, your test coverage will grow naturally.

Learn the basics

Start with our tutorials:

  1. Your First Test: Write and run your first test (15-20 min)
  2. Testing Thoroughly: Learn to test multiple scenarios (15-20 min)
  3. Testing Best Practices: Develop good testing habits (15-20 min)

Practice with examples

Study working code:

Build the habit

Make testing part of your workflow:

  • Write tests as you code (or shortly after)
  • Run tests before committing changes
  • Don't skip tests because you're "in a hurry" (bugs take longer to fix than tests take to write)
  • Celebrate when tests catch bugs – they're doing their job!

Be patient with yourself

Testing is a skill that improves with practice:

  • Your first tests will feel slow to write
  • You'll make mistakes and write bad tests
  • That's normal – everyone starts here
  • Keep practising, and it becomes second nature

Key takeaways

Let's recap why unit testing matters:

  1. Software complexity grows faster than human ability to track it – tests automate what we can't remember
  2. Bugs in production are expensive – catching them early saves time, money, and reputation
  3. Tests provide confidence, documentation, and refactoring safety – not just bug detection
  4. Unit tests should form the foundation of your testing strategy – they're fast, reliable, and catch most bugs
  5. Testing has a strong ROI – upfront cost is repaid many times over
  6. Focus tests where they provide value – complex logic, edge cases, critical functionality
  7. Common objections to testing are myths – testing saves time and improves code quality
  8. Testing is standard in professional development – not because it's trendy, but because it works

What's next?

Now that you understand why testing matters, learn how to test effectively:

Remember: Every expert developer started exactly where you are now. The only difference is practice. Start small, be consistent, and watch your confidence grow.

Happy testing!