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.
class Task:
def __init__(self, title, due_date=None):
self.title = title
self.due_date = due_date
self.completed = FalseEach 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:
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 = TrueRead 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.
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 overdueIt 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.
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:
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:
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:
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:
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:
coverage run -m pytest test_task_manager.py
coverage report -mThe 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:
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
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.
Frequently Asked Questions
How do I start testing a project that has no tests?
What should I do when a test fails but the code looks correct?
How do I decide which tests to write for a new feature?
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.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.