Python Testing With pytest
A test is a small piece of code that checks your real code gives the right answer, and pytest runs all your tests with one command.
Why Write Tests?
Every time you change a program, something old might break. Checking everything by hand is slow and easy to forget. Automatic tests check it for you in seconds.
- You find bugs before your users do
- You can change and improve code without fear
- Tests show other developers how your functions are meant to work
- Many freelance clients and companies expect tested code
Install pytest
Inside your virtual environment:
Example
pip install pytestThe Code to Test
Here is a small module for a shop. Save it as shop.py:
Example
# shop.py
def price_with_tax(price, rate=0.18):
if price < 0:
raise ValueError("price cannot be negative")
return round(price * (1 + rate))
def apply_discount(total, percent):
return total - total * percent / 100Your First Tests
Create test_shop.py in the same folder. Each test is a normal function whose name starts with test_. Inside it, use assert to state what must be true.
Example
# test_shop.py
import pytest
from shop import price_with_tax, apply_discount
def test_tax_is_added():
assert price_with_tax(1000) == 1180
def test_custom_rate():
assert price_with_tax(1000, rate=0.05) == 1050
def test_discount():
assert apply_discount(2000, 10) == 1800
def test_negative_price_is_rejected():
with pytest.raises(ValueError):
price_with_tax(-50)pytest.raises(ValueError) checks that the code inside does raise that error. If it does not, the test fails.
Running the Tests
In the terminal, in the project folder, type:
Example
pytest============================= test session starts ============================== platform linux -- Python 3.13.16, pytest-9.1.1, pluggy-1.7.0 rootdir: /home/ali/shop collected 4 items test_shop.py .... [100%] ============================== 4 passed in 0.02s ===============================
Each dot is a passing test. pytest found the tests by itself, because the file name starts with test_ and so do the function names.
Add -v (verbose) to see each test by name:
Example
pytest -vtest_shop.py::test_tax_is_added PASSED [ 25%] test_shop.py::test_custom_rate PASSED [ 50%] test_shop.py::test_discount PASSED [ 75%] test_shop.py::test_negative_price_is_rejected PASSED [100%] ============================== 4 passed in 0.02s ===============================
Reading a Failure
Suppose we write a test with a wrong expected value. 20% off Rs 1500 is Rs 1200, but we wrote 1300:
Example
def test_discount_wrong():
assert apply_discount(1500, 20) == 1300test_fail.py F [100%]
=================================== FAILURES ===================================
_____________________________ test_discount_wrong ______________________________
def test_discount_wrong():
> assert apply_discount(1500, 20) == 1300
E assert 1200.0 == 1300
E + where 1200.0 = apply_discount(1500, 20)
test_fail.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_fail.py::test_discount_wrong - assert 1200.0 == 1300
============================== 1 failed in 0.02s ===============================F means failed. The > points at the failing line, and the E lines show the real value (1200.0) next to the expected one (1300). Here the test was wrong, not the code. Sometimes it is the other way round, and that is a bug you just caught.
Many Inputs With parametrize
Instead of copying a test for every value, give pytest a table of inputs and expected results.
Example
import pytest
from shop import price_with_tax
@pytest.mark.parametrize(
"price, expected",
[
(100, 118),
(250, 295),
(0, 0),
(999, 1179),
],
)
def test_many_prices(price, expected):
assert price_with_tax(price) == expectedpytest runs the test once for each row:
test_more.py::test_many_prices[100-118] PASSED [ 16%] test_more.py::test_many_prices[250-295] PASSED [ 33%] test_more.py::test_many_prices[0-0] PASSED [ 50%] test_more.py::test_many_prices[999-1179] PASSED [ 66%]
Fixtures: Shared Test Data
A fixture prepares something that several tests need. Mark a function with @pytest.fixture, then use its name as a parameter in a test. pytest passes in a fresh copy every time.
Example
import pytest
@pytest.fixture
def cart():
return {"tea": 60, "samosa": 40, "juice": 120}
def test_cart_total(cart):
assert sum(cart.values()) == 220
def test_cart_has_tea(cart):
assert "tea" in carttest_more.py::test_cart_total PASSED [ 83%] test_more.py::test_cart_has_tea PASSED [100%]
Useful Commands
| Command | What it does |
|---|---|
pytest | Runs every test in the folder |
pytest -v | Shows each test by name |
pytest test_shop.py | Runs one file |
pytest -k discount | Runs tests whose name contains "discount" |
pytest -x | Stops at the first failure |
pytest --lf | Runs only the tests that failed last time |
Write a test every time you fix a bug. The test proves the bug is gone and makes sure it never quietly comes back.
Exercise
Exercise
How does pytest know which functions are tests?
