Test and Debug a Real Python Project

Apply testing and debugging skills to a complete Python project: write tests, find bugs with the debugger, measure coverage, and fix issues systematically.

8 min read

The previous articles in this section taught individual skills: writing your first unit test, mocking dependencies, measuring coverage, and debugging with pdb. This article puts them together by walking through a small but realistic Python project. You will write tests for existing code, find and fix bugs with the debugger, and bring the project to a state where every change is verified.

The project is a simple task manager with functions for adding tasks, marking them complete, and filtering by status. It has bugs. Your job is to find them and fix them, using the tools from this section.

The project code

Here is the task manager you will test and debug. Save it as task_manager.py. The project has two classes: Task holds the data for a single item, and TaskManager provides the operations.

pythonpython
class Task:
    def __init__(self, title, due_date=None):
        self.title = title
        self.due_date = due_date
        self.completed = False

Each task has a title, an optional due date, and a completed flag that starts as False. The TaskManager stores a list of these tasks and provides methods to add, complete, and filter them:

pythonpython
class TaskManager:
    def __init__(self):
        self.tasks = []
 
    def add_task(self, title, due_date=None):
        task = Task(title, due_date)
        self.tasks.append(task)
        return task
 
    def complete_task(self, index):
        self.tasks[index].completed = True

Read through the code before writing tests. The remaining methods handle filtering, and one of them already has a bug hiding in it. Notice that get_pending looks suspicious as soon as you read its return line.

pythonpython
    def get_pending(self):
        return [t for t in self.tasks if t.completed]
 
    def get_overdue(self, today):
        overdue = []
        for task in self.tasks:
            if task.due_date and task.due_date < today:
                overdue.append(task)
        return overdue

It filters for tasks where completed is True, but the method name suggests it should return tasks that are not yet completed. That is the first bug.

Writing the first tests

Create test_task_manager.py in the same directory. Start with the most fundamental behavior: adding a task and marking it complete.

pythonpython
import unittest
from task_manager import TaskManager
 
class TestTaskManager(unittest.TestCase):
    def setUp(self):
        self.manager = TaskManager()
 
    def test_add_task_returns_task(self):
        task = self.manager.add_task("Write tests")
        self.assertEqual(task.title, "Write tests")
        self.assertFalse(task.completed)

The setUp method gives every test a fresh, empty TaskManager, so tests never interfere with each other. Add the second test to the same class:

pythonpython
    def test_complete_task_marks_done(self):
        self.manager.add_task("Write tests")
        self.manager.complete_task(0)
        self.assertTrue(self.manager.tasks[0].completed)

Run the tests. Both should pass. The core behavior is now locked down.

Finding the first bug

The get_pending method is supposed to return tasks that are not yet completed, but it returns completed tasks instead. Write a test that exposes the bug:

pythonpython
def test_get_pending_excludes_completed(self):
    self.manager.add_task("Done task")
    self.manager.add_task("Active task")
    self.manager.complete_task(0)
    pending = self.manager.get_pending()
    titles = [t.title for t in pending]
    self.assertIn("Active task", titles)
    self.assertNotIn("Done task", titles)

Run this test. It fails because get_pending returns the completed task instead of the active one.

Fix the bug by inverting the condition, so the method returns tasks where not t.completed is true, then run the test again. It passes. You found a bug, wrote a test that reproduces it, and verified the fix.

Finding the second bug

The get_overdue method does not check whether a task is completed before reporting it as overdue. A task that was finished last week should not appear in the overdue list, even if its due date has passed.

Write a test for this scenario. Create two overdue tasks, complete one, and verify that get_overdue only returns the uncompleted one:

pythonpython
from datetime import date
 
def test_get_overdue_excludes_completed(self):
    past = date(2026, 6, 1)
    self.manager.add_task("Old completed", due_date=past)
    self.manager.add_task("Old pending", due_date=past)
    self.manager.complete_task(0)
    overdue = self.manager.get_overdue(date(2026, 7, 13))
    titles = [t.title for t in overdue]
    self.assertIn("Old pending", titles)

This test fails. The method returns both tasks because it does not filter out completed ones.

Fix it by adding and not task.completed to the condition, then run the test again. It passes.

Using the debugger on a failing test

When a test fails and the reason is not obvious from the output, set a breakpoint. Add breakpoint() to the test method just before the assertion:

pythonpython
def test_get_pending_excludes_completed(self):
    self.manager.add_task("Done task")
    self.manager.add_task("Active task")
    self.manager.complete_task(0)
    pending = self.manager.get_pending()
    breakpoint()
    titles = [t.title for t in pending]
    self.assertIn("Active task", titles)

Run the test. When execution pauses, inspect the pending list in the debugger. Type p [t.title for t in pending] to see the titles without stepping past the breakpoint.

You can see immediately whether the list contains the right tasks, which is faster than adding print statements and re-running. Once you have confirmed the values you expected, continue execution to let the test finish and report its result.

Measuring coverage

After fixing both bugs and writing tests for them, run coverage to see if there are untested code paths:

bashbash
coverage run -m pytest test_task_manager.py
coverage report -m

The report shows which lines in task_manager.py did not execute during tests. The constructor of Task is covered because add_task creates Task objects.

The due_date parameter is tested by the overdue tests. If any line shows as uncovered, write a test that exercises it. On a real project with many source files, add --source=task_manager to the coverage run so the report focuses on your own module instead of the standard library and any installed packages your tests import.

Adding the final tests

With the bugs fixed and the core behavior tested, add edge case tests to make the suite robust:

pythonpython
def test_complete_nonexistent_index_raises_error(self):
    with self.assertRaises(IndexError):
        self.manager.complete_task(99)
 
def test_get_pending_empty_manager(self):
    pending = self.manager.get_pending()
    self.assertEqual(pending, [])
 
def test_get_overdue_no_due_dates(self):
    self.manager.add_task("No due date")
    overdue = self.manager.get_overdue(date(2026, 7, 13))
    self.assertEqual(overdue, [])

These tests cover error conditions and edge cases. The complete_task test confirms that an invalid index raises an error. The empty manager test confirms that get_pending returns an empty list, not None. The no-due-dates test confirms that tasks without due dates are not incorrectly reported as overdue.

The complete workflow

This walkthrough followed the same workflow you should use on real projects:

  • Write a test for the happy path and run it to confirm the basic behavior works.
  • Write tests for edge cases and error conditions.
  • When a test fails, use the debugger to inspect the state, then fix the bug.
  • Run the tests to confirm the fix, and measure coverage to find untested code.
  • Repeat.

Every project is different, but this cycle of test, debug, fix, and verify applies universally. The tools change, but the process stays the same.

Rune AI

Rune AI

Key Insights

  • Start testing a legacy project by targeting the most critical functions first and building coverage incrementally.
  • Use the debugger to step through failing tests and watch variables change at each line.
  • Write a test that reproduces a bug before fixing it, then verify the fix makes the test pass.
  • Measure coverage after each round of testing to find untested code paths.
  • Run the full test suite before every commit and let CI enforce the same standard.
RunePowered by Rune AI

Frequently Asked Questions

How do I start testing a project that has no tests?

Start with the functions that would cause the most damage if they broke: payment processing, authentication, data validation. Write one test for the happy path of each critical function. Then write tests for edge cases. Do not try to achieve high coverage immediately. Build the test suite incrementally, adding tests for each bug you fix and each feature you add.

What should I do when a test fails but the code looks correct?

First, confirm the test itself is correct. Check that the expected value matches what the function should actually return. Set a breakpoint in the test and step into the function to watch the data flow. The code may be correct for the input you intended but not for the input the test is actually passing. Watch the variable values in the debugger to find where the assumption and reality diverge.

How do I decide which tests to write for a new feature?

Start with the acceptance criteria. If the feature says users can reset their password with a valid email, write a test for that. Then ask what could go wrong: invalid email, expired token, already-used token, missing email. Write a test for each failure mode. The acceptance criteria tell you what to test. The failure modes tell you what edge cases to cover.

Conclusion

Testing and debugging a real project is different from learning individual techniques in isolation. The skills compound. A well-written test makes debugging unnecessary. Good debugging habits make test failures trivial to fix. Together they turn a project that crashes unpredictably into one that fails loudly and fixes quickly.