7. Debugging and Testing
Learning Objectives
- Define debugging and explain how it differs from testing
- Apply at least three debugging techniques (print statements, logging, breakpoints) to locate a bug
- Write a unit test using Python's
unittestframework that checks both normal and edge-case inputs - Distinguish unit, integration, system, and regression testing and identify which applies to a given scenario
- Describe the Test-Driven Development (TDD) cycle and explain why writing tests first changes how code is designed
- Diagnose a buggy function by reading its behavior on edge cases rather than guessing
Quick Answer
Debugging is the process of finding out why a program isn't behaving as expected and fixing the underlying cause, not just the symptom. Testing is the complementary practice of deliberately running code against known inputs to check whether the outputs match what's expected — ideally before a bug reaches a user. Together they form the core discipline of writing reliable software: testing tells you that something is wrong, and debugging tells you why. Professional developers rarely trust code "because it looks right" — they write automated tests that catch regressions automatically, and when something does fail, they use structured techniques (print statements, logging, breakpoints, and stepping through the call stack) rather than guessing at random.
What Is Debugging?
Debugging is the systematic process of locating and correcting a bug — an unintended defect that causes a program to crash, produce wrong output, or behave unexpectedly. The word matters: debugging is not "randomly changing code until it seems to work." It is a search process — narrow down where the actual behavior diverges from the expected behavior, then fix the root cause.
def factorial(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
print(factorial(5)) # 120, correct
print(factorial(-3)) # 1, but is that the right answer for a negative input?
factorial(-3) doesn't crash — it silently returns 1, because range(1, -3 + 1) is range(1, -2), which is empty, so the loop body never runs and result stays at its initial value. This is more dangerous than a crash: the program keeps running with a wrong answer that nobody notices until it causes damage downstream.
Why It Matters
Debugging isn't a chore you tolerate before "real" programming — it's a core skill that consumes a large fraction of a professional developer's time. A bug caught during development costs minutes to fix; the same bug reaching production can cost hours of investigation, damaged user trust, or in safety-critical systems, real-world harm. Systematic debugging turns an unpredictable, frustrating process into a repeatable one.
Debugging Techniques
- Print statements — the simplest technique: insert
print()calls to show variable values at key points.
def factorial(n):
result = 1
for i in range(1, n + 1):
print(f"i={i}, result={result}") # temporary debugging output
result *= i
return result
- Logging — a more disciplined alternative to print statements. Log messages have severity levels and can be turned on or off without editing the code.
import logging
logging.basicConfig(level=logging.DEBUG)
def factorial(n):
result = 1
for i in range(1, n + 1):
logging.debug(f"i={i}, result={result}")
result *= i
return result
-
Breakpoints and step-through debugging — IDEs like VS Code and PyCharm let you pause execution at a specific line and inspect every variable's live value, then step forward one line at a time. This is far more powerful than print statements for complex bugs, because you don't have to guess in advance what to print.
-
Rubber duck debugging — explain your code, line by line, out loud to an inanimate object (or a patient colleague). The act of articulating what each line should do frequently reveals the gap between intention and implementation.
-
Reading the call stack — when a program crashes with an exception, the traceback shows every function call that led to the crash. Reading it from the bottom up (the actual error) and then upward (how you got there) is usually the fastest way to locate the root cause.
What Is Testing?
Testing means running a program against known inputs and checking that the outputs match what you expect — ideally automatically, so the check can be repeated every time the code changes. Testing doesn't tell you why something is broken (that's debugging's job); it tells you that something is broken, as early as possible.
Unit Testing in Python
A unit test checks one small piece of code — typically a single function — in isolation from the rest of the system.
import unittest
def factorial(n):
if n < 0:
raise ValueError("factorial is not defined for negative numbers")
result = 1
for i in range(1, n + 1):
result *= i
return result
class TestFactorial(unittest.TestCase):
def test_known_values(self):
self.assertEqual(factorial(5), 120)
self.assertEqual(factorial(0), 1)
self.assertEqual(factorial(1), 1)
def test_negative_raises_error(self):
with self.assertRaises(ValueError):
factorial(-3)
if __name__ == "__main__":
unittest.main()
Notice the second test — test_negative_raises_error — exists precisely because of the silent bug shown earlier. Once factorial is fixed to raise an error on negative input instead of silently returning 1, this test locks that fix in place so nobody can accidentally reintroduce the bug later.
Types of Testing
| Type | What It Checks | Example |
|---|---|---|
| Unit Testing | A single function or method in isolation | Does factorial(5) return 120? |
| Integration Testing | Whether multiple components work together correctly | Does the checkout module correctly call the payment module? |
| System Testing | The entire application end-to-end | Does the whole web app work when a user signs up and places an order? |
| Regression Testing | Whether a code change broke something that used to work | Re-running the full test suite after adding a new feature |
Test-Driven Development (TDD)
TDD flips the usual order: you write the test before the code that satisfies it.
- Write a test for a feature that doesn't exist yet — it fails, because there's nothing to pass it.
- Write the minimal code needed to make the test pass.
- Refactor the code for clarity or efficiency, re-running the test to confirm it still passes.
- Repeat for the next small piece of behavior.
# Step 1: write the test first (it will fail — is_even doesn't exist yet)
class TestIsEven(unittest.TestCase):
def test_even_number(self):
self.assertTrue(is_even(4))
def test_odd_number(self):
self.assertFalse(is_even(7))
# Step 2: write just enough code to pass
def is_even(n):
return n % 2 == 0
Why It Matters
Writing the test first forces you to think about the function's expected behavior — including edge cases — before you're anchored to a particular implementation. It also guarantees the codebase never accumulates untested code, since nothing gets written without a test demanding it first.
Key Terms
| Term | Definition | Related Concept |
|---|---|---|
| Bug | An unintended defect in code that causes incorrect behavior | Debugging |
| Debugging | The process of locating and fixing the root cause of a bug | Breakpoint, Call Stack |
| Breakpoint | A marker that pauses program execution at a specific line for inspection | Step-Through Debugging |
| Call Stack | The record of function calls that led to the current point of execution | Traceback |
| Unit Test | An automated test that checks one function or component in isolation | unittest, Assertion |
| Assertion | A statement that checks whether a condition is true, failing the test if not | Unit Test |
| Regression Testing | Re-running tests to confirm new changes haven't broken existing behavior | Test Suite |
| Test-Driven Development (TDD) | A workflow where tests are written before the code they verify | Unit Test |
Common Mistakes
Misconception: If a program doesn't crash, it doesn't have a bug.
Why it's wrong: The factorial(-3) example above runs without crashing but silently returns a wrong answer (1) instead of signaling an error. Silent logical bugs are often more dangerous than crashes because nobody notices them until the wrong output causes real damage downstream.
Correct understanding: Correctness means matching the intended behavior for every valid and invalid input, not merely "not crashing." Testing edge cases (negative numbers, empty inputs, zero) is essential precisely because they don't always crash.
Misconception: Debugging means adding print statements everywhere and reading through the output until something looks wrong. Why it's wrong: This "shotgun" approach is slow, doesn't scale to complex programs, and often misses the actual root cause because you're scanning for symptoms rather than testing a specific hypothesis. Correct understanding: Effective debugging forms a hypothesis about the cause first (based on the error message, call stack, or recent code changes), then uses a targeted print statement, log line, or breakpoint to confirm or reject that specific hypothesis.
Misconception: Writing tests is optional extra work that slows down development. Why it's wrong: Skipping tests only feels faster in the short term. Without them, every future change risks silently breaking existing behavior, and finding that breakage later (often in production) costs far more time than writing the test would have. Correct understanding: Tests are an investment that pays off the moment code changes again — which, in any real project, is almost immediately. Automated tests catch regressions in seconds instead of requiring manual re-verification of the whole system.
Comparison and Connections
| Feature | Debugging | Testing |
|---|---|---|
| Purpose | Find and fix why a specific bug happens | Verify code behaves correctly for given inputs |
| When it happens | After a problem is observed | Ideally continuously, before and after every change |
| Output | A code fix | Pass/fail results for a set of cases |
| Tools | Print statements, logging, breakpoints, debuggers | unittest, pytest, JUnit, CI pipelines |
| Automatable? | Partially (some tools automate stepping) | Yes — tests can run automatically on every commit |
| Test Type | Scope | Typical Question Answered |
|---|---|---|
| Unit | One function/method | Does this one piece work correctly alone? |
| Integration | Multiple components together | Do these pieces work correctly together? |
| System | Whole application | Does the complete product meet requirements? |
| Regression | Previously working features | Did a recent change break something that used to work? |
Practice Questions
Recall
-
What is the difference between debugging and testing? Look for: testing detects that something is wrong by comparing actual output to expected output; debugging investigates why it's wrong and fixes the root cause.
-
Name the four steps of the Test-Driven Development cycle. Look for: write a failing test, write minimal code to pass it, refactor, repeat.
Understanding
-
Explain why
factorial(-3)returning1is considered a bug even though the program doesn't crash. Look for: the function is silently producing an incorrect/undefined result for invalid input instead of signaling an error, which can mislead downstream code into treating the wrong answer as valid. -
Why is logging generally preferred over print statements in production code? Look for: logging supports severity levels (debug, info, warning, error), can be enabled/disabled without editing code, and can be directed to files or monitoring systems, whereas print statements are unstructured and must be manually removed.
Application
-
Write a unit test using
unittestthat verifies a functionis_prime(n)correctly identifies 7 as prime and 8 as not prime. Look for: aTestCasesubclass withassertTrue(is_prime(7))andassertFalse(is_prime(8)). -
A function
divide(a, b)should raise aValueErrorwhenbis 0. Write a test that checks this usingassertRaises. Look for:with self.assertRaises(ValueError): divide(5, 0)inside a test method.
Analysis
-
A student's regression test suite passes, but users report that a "search" feature stopped working after the latest update. What's the most likely gap in the test suite, and how would you fix it? Look for: the search feature likely has no unit or integration test covering it (a coverage gap), or the existing test doesn't test the specific input/scenario that broke; fix by adding a test that reproduces the reported failure, confirming it fails, then confirming the fix makes it pass.
-
Compare debugging with print statements versus using a step-through debugger for a bug that only appears after 500 loop iterations. Which approach is more practical and why? Look for: print statements would flood the output with 499 irrelevant lines before the useful one; a debugger lets you set a conditional breakpoint that only triggers on the relevant iteration, making the debugger far more practical for this scenario.
FAQ
Q: Should I write tests before or after writing the actual code? Either can work, but Test-Driven Development (writing tests first) forces you to clarify expected behavior — including edge cases — before you commit to an implementation. Writing tests after the code is still valuable, but it's easier to unconsciously write tests that just confirm what the code already does, bugs included.
Q: Do I need 100% test coverage? No. Coverage is a useful signal, not a goal in itself. It's more valuable to have well-designed tests for critical logic and edge cases than to chase a coverage percentage with shallow tests that don't actually verify correct behavior.
Q: What's the difference between an error, an exception, and a bug?
A bug is any defect that causes incorrect behavior. An exception is Python's mechanism for signaling that something went wrong during execution (like ValueError or FileNotFoundError) — exceptions are sometimes intentional and handled gracefully. An error more generally can refer to any incorrect state, whether or not an exception was raised.
Q: Why do developers use a debugger instead of just print statements if print statements work fine? Print statements require you to guess in advance exactly what to look at, and they clutter the code until removed. A debugger lets you pause execution and inspect any variable at any point, including ones you didn't think to print, which is far more efficient for complex or intermittent bugs.
Q: What is "regression" and why is it specifically called that? A regression is when a change reintroduces a bug that was previously fixed, or breaks a feature that used to work correctly. It's called a "regression" because the software has gone backward in quality for that specific behavior, even if new functionality was added elsewhere.
Quick Revision
- Debugging finds and fixes the root cause of incorrect behavior; testing detects that behavior is incorrect in the first place.
- A silent bug (wrong output, no crash) is often more dangerous than a crash because it can go unnoticed.
- Effective debugging forms a hypothesis first, then uses print statements, logging, or breakpoints to confirm it.
- Logging is preferred over print statements in real projects because it supports severity levels and can be toggled without code changes.
- A unit test checks one function in isolation using assertions (
assertEqual,assertRaises, etc.) via frameworks likeunittestorpytest. - Integration tests check that multiple components work together; system tests check the whole application; regression tests confirm old features still work after a change.
- TDD writes a failing test first, then the minimal code to pass it, then refactors — this clarifies expected behavior before implementation.
- Reading a traceback/call stack from the bottom (the actual error) upward is usually the fastest way to locate a crash's cause.
- Tests are an investment: they cost time upfront but save far more time by catching regressions automatically on every future change.
- 100% test coverage is not the goal — well-designed tests for critical logic and edge cases matter more than raw coverage percentage.
Related Topics
Prerequisites: Functions and Recursion, File Handling, Control Structures (loops and conditionals)
Related Topics: Basics of OOP (testing classes and methods), Exception Handling, Software Engineering practices (CI/CD)
Next Topics: Basics of OOP, Algorithm Design and Complexity Analysis, Software Development Life Cycle