Skip to content

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.

Live session

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:

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:

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:

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:

test_orders.py
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.