With v0.20 the project already has a history: you can go back to any earlier point and you know who changed what and why. But Git does not answer the question that really matters every time you touch a line: does everything else still work? Until now you answered it by hand, starting python -m easytask, creating a task, marking it, filtering, saving and quitting, option by option. That takes five minutes, it is done badly by the third time and it ends up being skipped on precisely the day it was needed. This lesson builds the missing safety net: automated tests that check on their own, in less than a second, that the program does what it should. You will see the pattern every test follows, the two tools of the Python ecosystem — unittest, which is included, and pytest, which everyone uses — what is worth testing and what is not, and why the separation between input/output and logic we made in 04-04 was, in fact, the preparation for this moment.

Contents

  1. Why test
  2. Kinds of test
  3. What makes code testable
  4. The AAA pattern and the test name
  5. assert: the first test
  6. unittest, the standard library
  7. pytest, the ecosystem's tool
  8. unittest versus pytest
  9. What to test and what not to
  10. Coverage
  11. TDD: red, green, refactor
  12. EasyTask v0.21: the tests/ folder
  13. Common mistakes and tips
  14. Exercises
  15. Conclusion

  1. Why test

An automated test is simply code that runs your code and checks the result is the expected one. It sounds like extra work, and it is the first time; it stops being extra as soon as you add up what the alternative costs:

  • Going through the menu by hand costs minutes on every change, and it gets worse over time: at first you check the ten cases, by the tenth time only the one you touched, and the bug turns up in another one.
  • Fear paralyses: without tests, improving code that already works feels reckless, so nobody improves it and the project rots.
  • A test is documentation that does not lie: test_complete_sets_done_to_days explains the expected behaviour better than any paragraph, and if the behaviour changes, the test fails.

The key word is regression: a bug that appears in something that used to work. Automated tests are the only practical defence against them, and they are what lets you refactor without fear, which is exactly what we will do in Style and refactoring.

  1. Kinds of test

Kind What it checks Size and speed Example in EasyTask
Unit One isolated piece: a function or a method Very fast, milliseconds Task.percentage() returns 50.0 with 2 of 4 days
Integration That several pieces fit together Slower Saving the agenda to JSON and loading it back
System The whole program, as the user uses it Slow and fragile Walking the menu simulating keystrokes

This course focuses on unit tests, with the odd integration one, and for a practical reason: they are fast, they point precisely at what is broken and they do not depend on the interface. A system test that walks the menu breaks as soon as you change a piece of text, and when it fails it does not say where the problem is. The healthy proportion in any project is the same: many unit tests, some integration tests, very few system tests.

  1. What makes code testable

This is where the debt from Breaking a program down into functions is paid, where we separated input and output from logic and said that would let us test the code. Compare these two versions:

# CANNOT BE PROPERLY TESTED: it reads the keyboard and prints
def compute_percentage():
    done = int(input("Days done: "))
    days = int(input("Total days: "))
    print(f"Progress: {done / days * 100:.1f}%")

# CAN BE TESTED: it takes data and returns a value
def percentage(done: int, days: int) -> float:
    """Return the progress percentage, between 0.0 and 100.0."""
    return min(100.0, done / days * 100)

The first is impossible to test in any reasonable way: to run it you have to simulate the keyboard, and to check the result you have to capture what it prints. The second is tested in one line: percentage(2, 4) == 50.0. The rule that follows is the one you already know under another name: functions that take data and return results are testable; those that talk to the world are not. That is why model.py and agenda.py do not have a single print, and why everything that can be checked lives there and not in interface.py.

  1. The AAA pattern and the test name

Every test in the world has the same three-step structure, known as AAA:

  1. Arrange: create the data and the objects the test needs.
  2. Act: run exactly one thing, the one being tested.
  3. Assert: verify that the result is the expected one.
def test_complete_marks_the_task_and_sets_the_days():
    task = Task("Book fair poster", "luis", "high", 4)           # Arrange
    task.complete()                                              # Act
    assert task.completed is True                                # Assert
    assert task.done == 4

And the name matters as much as the content, because it is the only thing you will see when the test fails. A good name says what is tested and what is expected: test_complete_marks_the_task_and_sets_the_days saves you from opening the file; test_1 or test_task say nothing. The usual convention is test_<what>_<condition>_<expected result>, and it does not matter if it turns out long: test names are read, not typed by hand.

  1. assert: the first test

Before using any tool at all, assert already lets you check things. Its form is assert condition, "message if it fails": if the condition is true nothing happens, and if it is false it raises an AssertionError with your message.

from easytask.model import Task

task = Task("Sole Bakery logo", "nuria", "high", 4)
assert task.priority == "high", "the priority should be normalised"
assert task.percentage() == 0.0, "a new task has no progress"
task.done = 2
assert task.percentage() == 50.0, f"expected 50.0 and got {task.percentage()}"
print("All the checks have passed.")

Saved in a file and run, it either passes in silence or blows up pointing at the line. It is a perfectly valid first step, and also the reason we said in 08-02 that the natural home of assert is tests. Its limits show up straight away: it stops at the first failure without checking the rest, it groups nothing, it gives no report and there is no way to run only part of it. That is what the tools are for.

  1. unittest, the standard library

unittest comes with Python, so there is nothing to install. Its tests are methods starting with test_ inside a class that inherits from unittest.TestCase:

# tests/test_model.py
import unittest
from easytask.model import Task, InvalidTask

class TestTask(unittest.TestCase):
    def setUp(self):
        """Runs BEFORE each test: every one starts from the same place."""
        self.task = Task("Book fair poster", "luis", "high", 4)

    def test_normalises_the_assignee_to_lowercase(self):
        task = Task("Vidal quote", "  MARTA ", "medium", 2)
        self.assertEqual(task.assignee, "marta")

    def test_complete_sets_the_days_done(self):
        self.task.complete()
        self.assertTrue(self.task.completed)
        self.assertEqual(self.task.done, 4)

    def test_invalid_priority_raises_exception(self):
        with self.assertRaises(InvalidTask):
            Task("Poster", "luis", "urgent", 3)

Four pieces worth understanding. setUp runs before each test method, so no test inherits another one's state — there is also tearDown, which runs afterwards and is used to clean up. The methods assertEqual, assertTrue, assertIn check equality, truth and membership. And assertRaises, used with with, checks the opposite of the usual: that the block does raise that exception; if it does not, the test fails. You run it from the project folder:

$ python -m unittest discover -s tests -v
test_complete_sets_the_days_done (test_model.TestTask) ... ok
test_invalid_priority_raises_exception (test_model.TestTask) ... ok
test_normalises_the_assignee_to_lowercase (test_model.TestTask) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.001s   OK

  1. pytest, the ecosystem's tool

pytest does not come with Python, but it is what practically everybody uses, because it removes the ceremony: no classes are needed and you check with the ordinary assert.

pip install pytest
# tests/test_agenda.py
import pytest
from easytask.model import Task, InvalidTask
from easytask.agenda import Agenda

def test_sorted_tasks_puts_high_priority_first():
    agenda = Agenda()
    agenda.add(Task("Vidal quote", "marta", "low", 2))
    agenda.add(Task("Book fair poster", "luis", "high", 4))
    assert [t.priority for t in agenda.sorted_tasks()] == ["high", "low"]

def test_invalid_priority_raises_invalid_task():
    with pytest.raises(InvalidTask):
        Task("Poster", "luis", "urgent", 3)

@pytest.mark.parametrize("done, days, expected", [
    (0, 4, 0.0), (2, 4, 50.0), (4, 4, 100.0), (8, 4, 100.0),
])
def test_percentage_computes_the_progress(done, days, expected):
    task = Task("Poster", "luis", "high", days)
    task.done = done
    assert task.percentage() == expected

Three new tools there. pytest.raises does the same as assertRaises. @pytest.mark.parametrize is the most profitable of them all: write one test and run it with four sets of data, which show up as four independent tests in the report; without it you would have written four almost identical functions. And look at the last case, (8, 4, 100.0): it is an edge case, more days done than planned, exactly the kind of thing parametrisation invites you to add.

Fixtures are pytest's way of preparing common data, the equivalent of setUp. They are declared with a decorator and requested by name as a parameter:

@pytest.fixture
def agenda_with_three():
    """Sample Alba Studio agenda."""
    agenda = Agenda()
    for t in (Task("Book fair poster", "luis", "high", 4),
              Task("Sole Bakery logo", "nuria", "medium", 5),
              Task("Vidal quote", "marta", "low", 2)):
        agenda.add(t)
    return agenda

def test_filter_by_assignee_returns_only_theirs(agenda_with_three):
    result = agenda_with_three.filter_by("assignee", "luis")
    assert len(result) == 1
    assert result[0].title == "Book fair poster"

And there is a built-in fixture that solves a very real problem: tmp_path, a different temporary folder for each test, which Python deletes when it finishes. It is what lets you test saving to a file without touching the real tasks.json:

def test_save_and_load_keeps_the_tasks(agenda_with_three, tmp_path):
    path = tmp_path / "tasks.json"           # temporary folder, not the real one
    storage.save(agenda_with_three, path)
    recovered = storage.load_tasks(path)
    assert len(recovered) == 3
    assert recovered.find("Sole Bakery logo").assignee == "nuria"

That is an integration test: it checks that Agenda, to_dict, from_dict and the storage module fit together. And it checks the only thing that matters about persistence: that what goes in comes back out the same (a save → load cycle, or round-trip).

  1. unittest versus pytest

Aspect unittest pytest
Installation Included in Python pip install pytest
Structure Classes inheriting from TestCase Plain functions
Checking self.assertEqual(a, b) assert a == b
Exceptions with self.assertRaises(E): with pytest.raises(E):
Common data setUp / tearDown Fixtures
Several cases One method per case @parametrize
Failure report Terse Very detailed: it shows the real values
Running python -m unittest discover pytest

Both are fine and they coexist: pytest also runs tests written with unittest. The practical recommendation: learn unittest because you will meet it in older projects and because it needs nothing installed, and write with pytest because it is shorter, more readable and its error report tells you which value came out and which one you expected.

  1. What to test and what not to

You do not test everything: you test what can break and what hurts. For each function it is worth covering three families of cases:

  • Normal cases: what happens 90 % of the time. A task with 2 of 4 days gives 50 %.
  • Edge cases: the borders, where most bugs live. Zero days done, an empty agenda, a list with a single element, more days done than planned, text with spaces or in uppercase.
  • Error cases: what must fail and how it must fail. A made-up priority has to raise InvalidTask, not return None in silence.

And there is a classic trap: testing the implementation instead of the behaviour. A test that checks Agenda internally keeps a list called _tasks will break the day you swap that list for a dictionary, even though the program still works perfectly. Always test what the function promises — what it returns, what it raises, how the object ends up — never its innards. The warning sign is easy to recognise: if refactoring without changing the behaviour breaks the tests, the tests were badly written.

  1. Coverage

Coverage measures what percentage of your lines the tests run. You get it with an external tool:

pip install coverage
coverage run -m pytest
coverage report -m

The report lists each file with its percentage and the lines that have never been run, and that is where its real usefulness lies: discovering whole branches nobody tests — typically the except blocks. But it is worth understanding its limit: coverage measures what is run, not what is checked. A test that calls every function without a single assert gives 100 % and guarantees absolutely nothing. A 90 % with tests that check the edge cases is worth infinitely more than an empty 100 %. Use it to find gaps, never as a target.

  1. TDD: red, green, refactor

TDD (Test-Driven Development) turns the usual order upside down: first you write the test, and only then the code that makes it pass. The cycle has three steps:

flowchart LR
    A[RED: write a test that fails] --> B[GREEN: the minimum code to pass it]
    B --> C[REFACTOR: clean up without breaking anything]
    C --> A

Writing the test first forces you to decide what the function must do before thinking about how, and it guarantees the test really checks something: if you have never seen it fail, you do not know whether it works. You do not have to apply TDD always — in this course we will not — but there is one case where it is clearly the best option: when you fix a bug. Write a test that reproduces it first (red), fix it (green) and that test will stay forever, stopping the bug from coming back.

  1. EasyTask v0.21: the tests/ folder

The tests live in their own folder, next to the package:

easytask/            <- the project: easytask/ (the package) and README.md
└── tests/
    ├── test_model.py
    └── test_agenda.py

The files in tests/ are the ones you saw in sections 6 and 7: test_model.py checks Task's validation, the assignee normalisation and complete(); test_agenda.py checks the order from sorted_tasks(), the filter by assignee and the save/load cycle with tmp_path. And this is a real run, with a real failure:

$ pytest -q
....F...
=================================== FAILURES ===================================
________________ test_percentage_computes_the_progress[8-4-100.0] _____________
    def test_percentage_computes_the_progress(done, days, expected):
        task.done = done
>       assert task.percentage() == expected
E       assert 200.0 == 100.0
E        +  where 200.0 = <Task 'Poster'>.percentage()
tests/test_model.py:34: AssertionError
1 failed, 7 passed in 0.06s

The failing test is the edge case (8, 4, 100.0): with more days done than planned, percentage() returned 200 %. The pytest report says it all: which specific case failed, which value came out (200.0) and which one was expected (100.0). The fix is one line in model.py:

    def percentage(self) -> float:
        """Return the progress percentage, between 0.0 and 100.0."""
        return min(100.0, self.done / self.days * 100)       # before, without min()
$ pytest -q
........
8 passed in 0.05s

EasyTask v0.21: eight tests that run in five hundredths of a second and verify what used to require walking the menu by hand. From here on, every change is validated with a single command, and the project has just gained the safety net it needed to be improved without fear. There are also continuous integration tools that run these very tests automatically on every push to the remote; that is well beyond this course, but it is good to know that the next step exists.

Common Mistakes and Tips

  • Testing functions that do input() and print(). You cannot. Extract the logic into a function that takes data and returns a value, and test that one.
  • Tests that depend on each other. Every test must be able to run on its own and in any order. That is what setUp and fixtures are for.
  • Touching the real data. A test that writes to tasks.json will wipe your work one day. Use tmp_path.
  • Testing the implementation, or using names like test_1 and test_task. If refactoring without changing the behaviour breaks the tests, they were badly written; and the name is the only thing you see when something fails.
  • Chasing 100 % coverage. It is easy to fake and guarantees nothing. Cover the edge cases and the error cases, which is where the bugs are.
  • Tip: when you find a bug, write the test that reproduces it first. It confirms you have understood it and stops it coming back ever again.

Exercises

Exercise 1: Making a function testable

This function cannot be tested. Split it in two — one with the logic and one with the conversation — and write two pytest tests for the logic part, including an edge case:

def record_hours():
    hours = float(input("Hours worked: "))
    rate = float(input("Rate per hour: "))
    total = hours * rate
    if hours > 8:
        total += (hours - 8) * rate * 0.25        # overtime surcharge
    print(f"Total: {total:.2f} EUR")

Exercise 2: Normal, edge and error cases

Write the tests for Agenda.find(title), which returns the task with that title (case-insensitive) or None if there is none. Cover a normal case, two edge cases and also check what happens with an empty agenda. Use a fixture for the sample agenda.

Exercise 3: Parametrising and testing files

Turn these three almost identical tests into a single parametrised one, and also write a test of the save/load cycle using tmp_path:

def test_high_priority_is_valid():
    assert Task("A", "luis", "high", 1).priority == "high"
def test_medium_priority_is_valid():
    assert Task("A", "luis", "MEDIUM", 1).priority == "medium"
def test_low_priority_is_valid():
    assert Task("A", "luis", " low ", 1).priority == "low"

Solutions

Solution 1.

# logic.py: takes data, returns a value. Testable.
WORKDAY = 8
OVERTIME_SURCHARGE = 0.25

def hours_cost(hours: float, rate: float) -> float:
    """Return the total cost, with a 25% surcharge from 8 hours onwards."""
    total = hours * rate
    if hours > WORKDAY:
        total += (hours - WORKDAY) * rate * OVERTIME_SURCHARGE
    return round(total, 2)

def record_hours() -> None:                    # the conversation, kept apart
    """Ask for the data from the keyboard and show the cost."""
    hours = float(input("Hours worked: "))
    rate = float(input("Rate per hour: "))
    print(f"Total: {hours_cost(hours, rate):.2f} EUR")

# tests/test_logic.py
def test_exactly_eight_hours_have_no_surcharge():   # edge case
    assert hours_cost(8, 30) == 240.0

def test_overtime_hours_apply_the_surcharge():
    assert hours_cost(10, 30) == 315.0              # 240 + 2*30*1.25

The edge case of exactly eight hours is the most valuable, because that is where the classic bug lives: a >= instead of a > would charge a surcharge for a normal working day, and only that test would catch it. Notice too that the conversation function ended up so simple that it no longer needs a test: all it does is call the one that is tested.

Solution 2.

@pytest.fixture
def agenda():
    a = Agenda()
    a.add(Task("Book fair poster", "luis", "high", 4))
    a.add(Task("Sole Bakery logo", "nuria", "medium", 5))
    return a

def test_find_locates_an_existing_task(agenda):
    assert agenda.find("Book fair poster").assignee == "luis"

def test_find_is_case_insensitive(agenda):                # edge case
    assert agenda.find("BOOK FAIR POSTER") is not None

def test_find_returns_none_if_it_does_not_exist(agenda):  # edge case
    assert agenda.find("Made-up task") is None

def test_find_in_an_empty_agenda_returns_none():
    assert Agenda().find("Book fair poster") is None

The four tests describe find's complete contract: it finds, it ignores case, it returns None when there is no match and it does not blow up with an empty agenda. None of them looks at how the collection is stored inside, so if tomorrow Agenda swaps its list for a dictionary, these tests will still pass: they check the behaviour, not the implementation.

Solution 3.

@pytest.mark.parametrize("entry, expected", [
    ("high", "high"), ("MEDIUM", "medium"), (" low ", "low"),
])
def test_the_priority_is_normalised(entry, expected):
    assert Task("A", "luis", entry, 1).priority == expected

def test_save_and_load_keeps_the_data(tmp_path):
    path = tmp_path / "tasks.json"
    agenda = Agenda()
    agenda.add(Task("Book fair poster", "luis", "high", 4))
    storage.save(agenda, path)
    recovered = storage.load_tasks(path)
    assert len(recovered) == 1
    assert recovered.find("Book fair poster").priority == "high"

Three tests have become one that still runs three times, and adding a fourth case now costs one line. The second test uses tmp_path to write into a temporary folder: it never touches the real tasks.json, and the folder disappears when it finishes. It is an integration test through and through, because for it to pass Agenda, Task.to_dict, from_dict and the two storage functions all have to work.

Conclusion

An automated test is code that runs your code and checks the result, and its real value is defending you from regressions: bugs that appear in something that used to work. There are unit, integration and system tests, and the healthy proportion is many of the first and very few of the last. What makes a function testable is exactly what we separated in 04-04: taking data and returning values, with no input() or print() in the way. Every test follows the AAA pattern — arrange, act, assert — and carries a descriptive name, which is the only thing you will see when it fails. With assert you can already check anything, and on that basis the two tools are built: unittest, included in Python, with TestCase classes, test_* methods, assertEqual/assertTrue/assertIn/assertRaises and setUp/tearDown, run with python -m unittest discover; and pytest, installed with pip, which does away with classes, checks with the ordinary assert, reports in detail which value came out and which was expected, and brings pytest.raises, @pytest.mark.parametrize to run one test with many sets of data, fixtures to prepare what is common and tmp_path to test files without touching the real data. You test the normal cases, the edge cases — where nearly all the bugs are — and the error cases, always against the behaviour and never against the implementation. Coverage is for finding gaps, not as a target: it measures what is run, not what is checked. And TDD — red, green, refactor — is especially useful when fixing a bug: first the test that reproduces it, then the fix.

EasyTask is now v0.21: a tests/ folder with test_model.py and test_agenda.py, eight tests that run in hundredths of a second and that have already found a real bug — the progress that went over 100 % — and left it fixed and protected forever. With this you have the four pieces that were missing at the end of module 7: documentation, error handling, history and tests. One last thing remains, and it is what separates a program that works from a program you are proud of: how it is written. There are functions in interface.py that have grown to forty lines, loose numbers repeated across several files, names inherited from when the program was an exercise and duplicated chunks of code. In Style, readability and refactoring all of that gets fixed with PEP 8, with automatic tools and with a catalogue of refactorings — leaning, precisely, on the tests you have just written.

© Copyright 2026. All rights reserved