Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, you followed a traceback to a broken progress message and used the debugger to confirm that its file count was an integer. You then fixed the message without changing the count itself. Now you’ll protect that behavior with pytest unit tests: one for an ordinary result, one for a boundary case, and one for an expected error. Allow about 40 minutes, including the short video segments and coding.
Decide what the unit promises
The function you corrected is a useful first unit to test:
def progress_message(file_count: int) -> str:
return f"Processed {file_count} files"
Its job is small: turn a file count into a message. A unit test can call it directly, without starting the CLI, reading an inventory, or writing a report. That isolation matters: if the test fails, you can investigate the formatting function rather than several layers of the application at once.
Before writing assertions, specify three kinds of behavior:
- Normal case: a count of
1produces"Processed 1 files". - Edge case: an empty inventory has a count of
0and produces"Processed 0 files". - Expected exception: a negative count is invalid and should raise
ValueError.
The wording "1 files" is awkward, but it is the behavior established in the previous lesson. Keep these tests focused on preserving that behavior; changing the wording would be a separate, intentional change to the contract. The negative-count rule, by contrast, is a new boundary rule you will implement after seeing its test fail.
Watch the selected parts of Please Learn How To Write Tests in Python… • Pytest Tutorial from Tech With Tim. Its examples use different functions; pay attention to test discovery, failed assertions, varied inputs, and pytest.raises. The video mentions pytest-mock, but you do not need it for this lesson.
Please Learn How To Write Tests in Python… • Pytest Tutorial
Tech With Tim demonstrates the small amount of code needed to run a pytest test and then shows why ordinary inputs alone are insufficient.
Watch a first test for the test-file and test-function naming convention and the failure report. Then watch edge cases for varied inputs and an expected exception.
Make the normal and edge cases executable
From your project root, activate the virtual environment you have used for the CLI. Install pytest there if it is not already installed:
python -m pip install pytest
Record pytest in the project’s dependency manifest using the development-dependency convention you established earlier. Keep using the same environment when running tests. If repo_assistant cannot be imported, restore your project’s existing installation setup rather than copying application code into the test file.
Pytest discovers files named test_*.py and functions named test_*. Create tests/test_cli.py:
import pytest
from repo_assistant.cli import progress_message
@pytest.mark.parametrize(
("file_count", "expected"),
[
(1, "Processed 1 files"),
(0, "Processed 0 files"),
(125, "Processed 125 files"),
],
ids=["one-file", "empty-inventory", "many-files"],
)
def test_progress_message_formats_counts(
file_count: int, expected: str
) -> None:
assert progress_message(file_count) == expected
Each input-and-expected-output pair becomes a separately reported test case. The count of 1 checks the familiar path; 0 checks a meaningful boundary; 125 checks that the function formats a multi-digit count rather than accidentally relying on the sample inventory. The IDs make a failure easier to locate.
Here, assert states the requirement. If a result differs, pytest shows the compared values; a test that merely calls progress_message() without asserting anything would not check its message.
The pytest documentation’s How to write and report assertions in tests explains both ordinary assertions and exception assertions. Read these portions before running your first test:
How to write and report assertions in tests
Read pytest’s explanation of what a failed assert reveals, then its examples of checking an expected exception.
Under “Asserting with the assert statement,” begin with the explanation of verifying values and read the example and report; stop before the paragraph about adding a custom assertion message. Under “Assertions about expected exceptions,” read the raises examples. Finally, under “Matching exception messages,” read the match example and its first note.
Run just this test file:
python -m pytest -q tests/test_cli.py
You should see three passing cases. If a case fails, compare the expected string with the actual string before changing either one. A test is useful only when its expectation comes from the intended behavior, not from whatever the code happened to return.
Test an error that should happen
A negative file count cannot describe a valid inventory. Add a separate test to tests/test_cli.py:
def test_progress_message_rejects_negative_count() -> None:
with pytest.raises(ValueError, match="nonnegative"):
progress_message(-1)
The call must be inside the with block. This test passes only if that call raises ValueError with a message containing nonnegative. The match argument is a regular-expression search of the exception message; here the simple word makes the intention clear.
Run the file again. The new test should fail because the current f-string formats -1 instead of rejecting it. A failure saying that ValueError was not raised is useful evidence: your test has reached the behavior you intend to add.
Now update the existing helper in src/repo_assistant/cli.py:
def progress_message(file_count: int) -> str:
if file_count < 0:
raise ValueError("file_count must be nonnegative")
return f"Processed {file_count} files"
This guard makes the new rule explicit. The int annotation documents the expected input type, but—as you saw while debugging—it does not enforce rules at runtime. This guard checks the particular rule the test specifies; it is not a substitute for every possible input-validation policy.
Rerun the tests:
python -m pytest -q tests/test_cli.py
You should now have four passing cases: three formatted counts and one expected exception. Unlike a normal assertion failure, an exception raised inside pytest.raises is the success condition. If no exception occurs, or the wrong type occurs, pytest reports a failure.
Keep each failure informative
Parameterization is helpful when only the inputs and expected outputs vary. Watch the final selected segment of the Tech With Tim tutorial to see how one test body can produce independently reported cases.
Please Learn How To Write Tests in Python… • Pytest Tutorial
This portion of Tech With Tim’s pytest tutorial demonstrates parameterization without requiring you to repeat the same assertion in several functions.
Watch parameterized tests. Compare the video’s input-and-expected-result pairs with the three pairs in your test_progress_message_formats_counts test.
Keep the exception in its own test: it describes a different outcome from returning a string. That separation makes both test names and failure reports clearer. Notice also what these tests do not claim to verify. They check a helper’s behavior, not whether the CLI displays the message only under --verbose or saves a report. Those are broader checks involving other parts of the application.
Finally, run the full test suite from the project root:
python -m pytest -q
If the targeted file passes but the full suite fails, inspect the other failure rather than assuming these four tests caused it. Tests should be repeatable and independent: none of the tests above needs a real inventory file, a pre-existing output directory, or a particular execution order.
What you can carry forward
A useful unit test names a behavior, supplies a deliberate input, and checks the resulting value or expected exception. You have now covered an ordinary count, the zero-count boundary, and an invalid negative count. Pytest’s parameterization keeps similar return-value checks concise, while pytest.raises makes an error part of the specified behavior rather than an unexplained test failure.
In the next lesson, you’ll use a Git branch, focused commits, and a merge to complete a small feature safely. These tests will give you a quick way to check that the feature still behaves as intended before you merge it.