Skip to Content
Python Intermediate: Write Code You Won't Be Embarrassed to Share
Intermediate
0 likes
Courses
Python Intermediate: Write Code You Won't Be Embarrassed to Share

Python Testing Basics With pytest

Write and run tests with pytest, read a failing test, and test errors and edge cases. After this, you can change your code and know within seconds if you broke something.

Course content

Sign in to save your progress.

Course Home

A test is a small piece of code that runs your code and checks the result. Writing tests feels slower on day one. It is faster from day two, because every change after that gets checked in seconds instead of by hand, or by a customer.

Skip ahead: If you already write pytest tests, read its failure output, use pytest.raises and parametrize, continue to "Debugging Python With print, logging and pdb."

Do you need tests for this?

Not for everything. A one-time script you run once and delete doesn't need tests. Code that runs every week, code that touches money or other people's data, and code that more than one person changes does. If you would be embarrassed by a wrong result, test it.

Install pytest

Inside the project's virtual environment:

python -m pip install pytest

The code to test

# pricing.py
def apply_discount(amount, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return round(amount * (100 - percent) / 100, 2)


def shipping_fee(subtotal):
    if subtotal >= 1000:
        return 0
    return 150

Your first tests

Make a file whose name starts with test_, with functions whose names start with test_. Inside, use plain assert:

# test_pricing.py
from pricing import apply_discount, shipping_fee


def test_discount_ten_percent():
    assert apply_discount(1500, 10) == 1350


def test_no_discount():
    assert apply_discount(999.99, 0) == 999.99


def test_free_shipping_at_1000():
    assert shipping_fee(1000) == 0


def test_shipping_below_1000():
    assert shipping_fee(999) == 150
python3 -m pytest -q -p no:cacheprovider
....                                                                     [100%]
4 passed in ...s

Each dot is a passing test. pytest found the file and the functions by their names. No classes, no setup. (-q keeps the output short. You can leave it off, and -p no:cacheprovider is only there to stop pytest writing a cache folder in this example.)

Reading a failure

Now someone changes the free-shipping rule and makes a small mistake:

# pricing.py
def apply_discount(amount, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return round(amount * (100 - percent) / 100, 2)


def shipping_fee(subtotal):
    if subtotal > 1000:   # changed from >= by mistake
        return 0
    return 150
python3 -m pytest -q -p no:cacheprovider
..F.                                                                     [100%]
=================================== FAILURES ===================================
__________________________ test_free_shipping_at_1000 __________________________

    def test_free_shipping_at_1000():
>       assert shipping_fee(1000) == 0
E       assert 150 == 0
E        +  where 150 = shipping_fee(1000)

test_pricing.py:14: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_free_shipping_at_1000 - assert 150 == 0
1 failed, 3 passed in ...s

Read it from the bottom, like a traceback. Which test failed, in which file and line, and the actual values: shipping_fee(1000) returned 150 where the test expected 0. You know exactly what broke before opening pricing.py. Without the test, the first person to notice would be a customer paying 150 for shipping on a 1,000 order.

Testing that errors happen

Good code refuses bad input. Test that too, with pytest.raises:

# test_errors.py
import pytest
from pricing import apply_discount


def test_discount_over_100_is_refused():
    with pytest.raises(ValueError, match="between 0 and 100"):
        apply_discount(1000, 150)

If apply_discount doesn't raise ValueError with that message, the test fails.

Many cases, one test: parametrize

Bugs live at the edges: zero, the exact limit, one below it. Instead of writing a function per case, list the cases:

# test_shipping.py
import pytest
from pricing import shipping_fee


@pytest.mark.parametrize("subtotal, expected", [
    (0, 150),
    (999.99, 150),
    (1000, 0),
    (5000, 0),
])
def test_shipping_fee(subtotal, expected):
    assert shipping_fee(subtotal) == expected
python3 -m pytest -q -p no:cacheprovider test_errors.py test_shipping.py
...F.                                                                    [100%]
...
FAILED test_shipping.py::test_shipping_fee[1000-0] - assert 150 == 0
1 failed, 4 passed in ...s

The error test passes, and the parametrized test pinpoints the exact case: subtotal 1000. 999.99 and 5000 still behave. The test ID in square brackets, [1000-0], tells you which row of the list failed. Now fix the bug and run everything:

sed -i 's/subtotal > 1000:   # changed from >= by mistake/subtotal >= 1000:/' pricing.py
python3 -m pytest -q -p no:cacheprovider
.........                                                                [100%]
9 passed in ...s

(The sed line just edits the file for this example. Normally you fix it in your editor.) Nine tests, all passing, in a fraction of a second. Checking the same nine cases by hand every time someone touches the pricing would take minutes, and nobody would actually do it.

⚠ Warning: Never "fix" a failing test by changing the expected value until it passes, unless you have confirmed the rule itself changed. A test that fails is reporting a difference between what the code does and what someone decided it should do. Find out which one is wrong first. Editing the test to match the bug is how wrong behaviour becomes official.

What to test first

  • Rules with money, dates or limits. Discounts, fees, deadlines, age checks.
  • The edges. Zero, empty list, exactly the limit, one below it.
  • Every bug you fix. Write a test that fails because of the bug, then fix it. That bug can never quietly come back.
  • Cleaning functions. Like clean_row from the CSV lesson: give it a bad row and check it raises the right error.

Common mistakes

  • Tests that don't check anything. A test that calls a function without assert passes as long as nothing crashes. That proves very little.
  • Comparing floats exactly after calculations. 0.1 + 0.2 == 0.3 is False in Python. Use pytest.approx, for example assert total == pytest.approx(0.3), or round like apply_discount does.
  • Tests that depend on each other or on the real database. Each test should set up what it needs and run on its own, in any order.
  • Naming the file pricing_test.py or tests.py. pytest finds test_*.py and *_test.py by default. Stick to test_*.py and you never have to think about it.

💡 Tip: Run one test while you work on it with python -m pytest -k shipping (any test with "shipping" in its name), and the whole suite before you commit. Add -x to stop at the first failure when many tests fail for the same reason.

What happens if you skip this step

Every change becomes a small gamble. You fix one thing, you check the part you changed, and something two functions away breaks without anybody knowing. Eventually people stop changing the code because they are afraid of it. Tests are what make it safe to keep improving code that already works.

The free-shipping limit changes to 1,500 next month. Without tests, how would you prove that every place that uses it got updated correctly?

About

Write and run tests with pytest, read a failing test, and test errors and edge cases. After this, you can change your code and know within seconds if you broke something.

Duration
10 min
Last update
09/11/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.