Image by Author | Created on Canva
In software development, unit testing is important to check that individual components of your code work correctly. At its core, unit testing is the practice of testing individual units—functions, methods, classes—of your code to ensure they work as expected. Think of it as zooming in on small sections of your code and asking, “Does this piece of code do what it’s supposed to?”
Good unit tests help you ensure that bugs are caught early, code remains maintainable, and you can refactor without introducing new issues. In this tutorial, we’ll do the following:
- Build a simple TO-DO list manager
- Write unit tests for different functionalities using PyTest
- Run tests and interpret the results
Let’s get started.
Setting Up the Project
▶️ You can find the code for this tutorial on GitHub.
Before we jump into coding, let’s set up a clean folder structure for our project. This will help organize the code and tests properly. Here’s the recommended folder structure:
todo_project/
│
├── todo/
│ ├── __init__.py
│ └── todo_manager.py
│
├── tests/
│ ├── __init__.py
│ └── test_todo_manager.py
│
└── requirements.txt
Here’s a quick breakdown:
todo/
: This folder contains the core application logic: theToDoManager
class that we will write. We also add an empty__init__.py
file so we can import and use functions and classes in this module.tests/
: This folder holds all the test files. We’ll place our unit tests here, keeping them separate from the main code.
If you have a list of dependencies, you can create a requirements.txt file and install the libraries in a virtual environment like so:
For this tutorial, we only need PyTest. So you can create and activate a virtual environment:
$ python3 -m venv v1
$ source v1/bin/activate
Then install PyTest using pip:
Now that we’ve installed PyTest, let’s start coding!
Creating the To-Do List Manager
We’ll now build the To-Do list app. The ToDoManager
class allows users to add tasks, remove tasks, mark them as completed, and retrieve tasks based on their completion status:
# todo/todo_manager.py
class ToDoManager:
def __init__(self):
self.tasks = []
def add_task(self, task):
if not task:
raise ValueError("Task cannot be empty.")
self.tasks.append("task": task, "completed": False)
return task
def remove_task(self, task):
for t in self.tasks:
if t["task"] == task:
self.tasks.remove(t)
return task
raise ValueError("Task not found.")
def mark_completed(self, task):
for t in self.tasks:
if t["task"] == task:
t["completed"] = True
return task
raise ValueError("Task not found.")
def get_tasks(self, completed=None):
if completed is None:
return self.tasks
return [t for t in self.tasks if t["completed"] == completed]
The add_task
method adds a new task to the list. It raises an error if the task is empty. The remove_task
method removes a task from the list. Raises an error if the task doesn’t exist.
The mark_completed
marks a task as completed. It raises an error if the task is not found. The get_tasks(completed=None)
method retrieves tasks from the list. You can filter by completed or pending tasks using the completed
parameter.
Writing Unit Tests with PyTest
Now that we have a working class, it’s time to write unit tests for it. We will write our tests in the tests/test_todo_manager.py
file. Let’s write tests for each method in our ToDoManager
class.
We create a fixture that initializes the ToDoManager
before each test. This ensures that each test has a clean instance of the class and prevents side effects.
# tests/test_todo_manager.py
import pytest
from todo.todo_manager import ToDoManager
@pytest.fixture
def todo_manager():
return ToDoManager()
It’s now time to write the test cases—each test case focusing on one method:
Method | Description |
test_add_task | Tests if tasks are added correctly |
test_add_empty_task | Ensures that an empty task raises an error |
test_remove_task | Checks if a task can be successfully removed |
test_remove_nonexistent_task | Tests if an error is raised when trying to remove a non-existent task |
test_mark_completed | Tests if tasks can be marked as completed |
test_get_tasks | Retrieves all tasks and filters completed and pending tasks |
# tests/test_todo_manager.py
import pytest
from todo.todo_manager import ToDoManager
...
def test_add_task(todo_manager):
todo_manager.add_task("Buy groceries")
assert todo_manager.tasks == ["task": "Buy groceries", "completed": False]
def test_add_empty_task(todo_manager):
with pytest.raises(ValueError, match="Task cannot be empty."):
todo_manager.add_task("")
def test_remove_task(todo_manager):
todo_manager.add_task("Buy groceries")
todo_manager.remove_task("Buy groceries")
assert todo_manager.tasks == []
def test_remove_nonexistent_task(todo_manager):
with pytest.raises(ValueError, match="Task not found."):
todo_manager.remove_task("Do laundry")
def test_mark_completed(todo_manager):
todo_manager.add_task("Go for a walk")
todo_manager.mark_completed("Go for a walk")
assert todo_manager.tasks == ["task": "Go for a walk", "completed": True]
def test_get_tasks(todo_manager):
todo_manager.add_task("Task 1")
todo_manager.add_task("Task 2")
todo_manager.mark_completed("Task 1")
all_tasks = todo_manager.get_tasks()
completed_tasks = todo_manager.get_tasks(completed=True)
pending_tasks = todo_manager.get_tasks(completed=False)
assert len(all_tasks) == 2
assert completed_tasks == ["task": "Task 1", "completed": True]
assert pending_tasks == ["task": "Task 2", "completed": False]
Running the Tests
Let’s now run the tests. Navigate to the root of your project (here todo_project
) and run the following command:
PyTest will auto-discover the tests in the tests/
folder and execute them. If everything is correct, you should see output like this:
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-8.3.3, pluggy-1.5.0
rootdir: /home/balapriya/pytest_t
collected 6 items
tests/test_todo_manager.py ...... [100%]
============================== 6 passed in 0.02s ===============================
This means all our tests passed successfully!
Wrapping Up
Unit tests allow you to catch bugs early in the development cycle—saving time and resources. When you make changes or refactor code, unit tests ensure that the code works as expected. Writing unit tests forces you to design your code in a modular and testable way.
In this tutorial, we walked through building a To-Do list app and writing unit tests using PyTest. By following best practices for organizing your project, you can scale your application and tests as it grows. Unit testing, especially with PyTest, makes your code more robust, maintainable, and bug-free.
Happy unit testing!
Bala Priya C is a developer and technical writer from India. She likes working at the intersection of math, programming, data science, and content creation. Her areas of interest and expertise include DevOps, data science, and natural language processing. She enjoys reading, writing, coding, and coffee! Currently, she’s working on learning and sharing her knowledge with the developer community by authoring tutorials, how-to guides, opinion pieces, and more. Bala also creates engaging resource overviews and coding tutorials.