Week 2 · Python for real work
Testing with pytest
Write your first automated tests with pytest, read the results and check that your code handles bad input.
So far, you've tested code by running it and reading the output. That works once, but it doesn't scale: every time you change something, you'd have to check everything again by hand. An automated test is a small piece of code that checks your code for you, so you can repeat every check in seconds.
The usual tool for this in Python is pytest.
Install pytest
Like mypy, pytest is only needed by developers. In your learn-types project, run:
uv add --dev pytest
Your first test
Create the code you want to test in a file called orders.py:
def apply_discount(total, rate):
if rate < 0 or rate > 1:
raise ValueError(f"Discount rate must be between 0 and 1, got {rate}")
return total - total * rate
def is_large(total):
"""An order of Rs 10,000 or more is large."""
return total > 10000
Then create the tests in a file called test_orders.py:
from orders import apply_discount, is_large
def test_half_price():
assert apply_discount(1000, 0.5) == 500
def test_small_order_is_not_large():
assert not is_large(4500)
assert checks that something is true. If it isn't, the test fails.
pytest finds your tests by their names, so two rules matter:
- The file name starts with
test_. - Each test function's name starts with
test_.
Give each test a name that says what it checks, so that a failure tells you what broke.
Running your tests
uv run pytest
You'll see a few lines about your setup, then something like this:
test_orders.py .. [100%]
============================== 2 passed in 0.01s ===============================
Each . is a test that passed.
When a test fails
is_large() has a bug, because an order of exactly Rs 10,000 should count as large. Add a test for that to test_orders.py:
def test_exactly_ten_thousand_is_large():
assert is_large(10000)
Run uv run pytest again:
test_orders.py ..F [100%]
=================================== FAILURES ===================================
______________________ test_exactly_ten_thousand_is_large ______________________
def test_exactly_ten_thousand_is_large():
> assert is_large(10000)
E assert False
E + where False = is_large(10000)
test_orders.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_orders.py::test_exactly_ten_thousand_is_large - assert False
========================= 1 failed, 2 passed in 0.09s ==========================
The F marks the failure. The > points to the line that failed, and the E lines show why: is_large(10000) returned False.
To fix it, change > to >= in orders.py and run the tests again. All three should pass, and that test now protects you if the bug ever comes back.
Testing that errors are raised
When your code should raise an error, test that too. pytest.raises passes only if the code inside it raises that error:
import pytest
from orders import apply_discount
def test_rate_above_one_is_rejected():
with pytest.raises(ValueError):
apply_discount(12800, 10)
Put import pytest at the top of your test file, with your other imports.
What to test
You can't test every possible value, so choose the ones most likely to find a bug:
- A normal case: a typical order.
- Boundaries: exactly 10,000, then just below it. Most bugs hide here.
- Bad input: a rate of 10, a blank name or a missing file.
When you find a bug, write a test that fails because of it before you fix it. Then you know both that the fix works and that the bug can't come back unnoticed. This also matters when an AI tool writes your code, because a test checks what the code does and not how convincing it looks.