Testing with pytest
pytest is the most widely used test framework for Python. Tests are plain functions with plain assert statements, yet when something fails pytest shows exactly which values differed. It can also run your existing unittest tests, so teams can switch gradually. This lesson uses pytest 9.2, the current release.
In this lesson you will write pytest tests, share set-up with fixtures, run one test with many inputs using parametrize, group tests with markers, and learn the command-line options testers use every day.
Install and first tests
Install pytest in your project's virtual environment:
python -m pip install pytest
pytest --versionpytest finds files named test_*.py (or *_test.py) and, inside them, functions whose names start with test. A test is just a function with assert:
# test_shop.py
import pytest
from shop import add_gst, delivery_charge
def test_gst_default_rate():
assert add_gst(100) == 118.0
def test_negative_price_rejected():
with pytest.raises(ValueError, match="negative"):
add_gst(-1)pytest.raises passes only if the code raises that exception, and match checks the error message with a regular expression. There is no class to inherit from and no special assert method to remember.
pytest itself cannot run in the browser editor, but the core idea is simple enough to imitate. This runnable sketch finds every function whose name starts with test_ and runs it, just as pytest does:
def delivery_charge(total):
return 0 if total > 499 else 40 # defect: rule is 499 or more
def test_small_order_pays():
assert delivery_charge(120) == 40
def test_exactly_499_is_free():
assert delivery_charge(499) == 0, "499 should be free"
def test_big_order_is_free():
assert delivery_charge(2500) == 0
# A tiny version of what pytest does: find test_ functions and run them
tests = [obj for name, obj in list(globals().items()) if name.startswith("test_") and callable(obj)]
passed = failed = 0
for test in tests:
try:
test()
passed += 1
print(f"PASSED {test.__name__}")
except AssertionError as error:
failed += 1
print(f"FAILED {test.__name__} - {error}")
print(f"{failed} failed, {passed} passed")PASSED test_small_order_pays FAILED test_exactly_499_is_free - 499 should be free PASSED test_big_order_is_free 1 failed, 2 passed
Real pytest does much more: it rewrites assert statements so a failure shows the values on both sides, without you writing any message.
Fixtures
A fixture is a function that prepares something a test needs, such as a cart, a database connection or a logged-in browser. A test asks for a fixture simply by naming it as a parameter. Fixtures placed in a file called conftest.py are available to every test in that folder, without importing them.
# conftest.py
import pytest
from shop import Cart
@pytest.fixture
def cart():
"""A cart that already holds two notebooks at Rs 60 each."""
c = Cart()
c.add("notebook", 60, qty=2)
return c# in test_shop.py
def test_cart_total(cart):
assert cart.total() == 120
def test_adding_again_increases_quantity(cart):
cart.add("notebook", 60, qty=3)
assert cart.items["notebook"] == (60, 5)By default each test gets a fresh fixture, like setUp() in unittest. For clean-up, write yield c instead of return c: the code after yield runs once the test finishes. A fixture can have a wider scope (@pytest.fixture(scope="session")) when it is expensive to create, such as starting a browser once for all tests. pytest also ships built-in fixtures, for example tmp_path (a fresh temporary folder), monkeypatch (temporarily change environment variables or functions) and capsys (capture printed output).
parametrize: one test, many inputs
@pytest.mark.parametrize runs the same test once per row of data, and reports each row separately. It is the pytest way to apply boundary value analysis:
@pytest.mark.parametrize(
("total", "expected"),
[(498, 40), (499, 0), (500, 0), (0, 40)],
)
def test_delivery_charge(total, expected):
assert delivery_charge(total) == expectedMarkers and running a subset
Markers label tests so you can choose which to run. Register your own markers in the configuration; pytest 9 can read native TOML settings from a [tool.pytest] table in pyproject.toml (older projects use [tool.pytest.ini_options] or a pytest.ini file):
[tool.pytest]
markers = ["slow: tests that take a long time"]@pytest.mark.slow
def test_many_items(cart):
for n in range(1000):
cart.add(f"item{n}", 1)
assert cart.total() == 1120
def test_float_maths():
assert 0.1 + 0.2 == pytest.approx(0.3)pytest.approx compares floating-point numbers with a small tolerance, because 0.1 + 0.2 is not exactly 0.3 in binary.
Reading the output
Run pytest -v in the project folder. Here delivery_charge() still has the boundary defect:
============================= test session starts =============================
platform win32 -- Python 3.14.8, pytest-9.2.0, pluggy-1.7.0
rootdir: C:\projects\shop
configfile: pyproject.toml
collecting ... collected 10 items
test_shop.py::test_gst_default_rate PASSED [ 10%]
test_shop.py::test_negative_price_rejected PASSED [ 20%]
test_shop.py::test_delivery_charge[498-40] PASSED [ 30%]
test_shop.py::test_delivery_charge[499-0] FAILED [ 40%]
test_shop.py::test_delivery_charge[500-0] PASSED [ 50%]
test_shop.py::test_delivery_charge[0-40] PASSED [ 60%]
test_shop.py::test_cart_total PASSED [ 70%]
test_shop.py::test_adding_again_increases_quantity PASSED [ 80%]
test_shop.py::test_many_items PASSED [ 90%]
test_shop.py::test_float_maths PASSED [100%]
================================== FAILURES ===================================
_________________________ test_delivery_charge[499-0] _________________________
total = 499, expected = 0
@pytest.mark.parametrize(
("total", "expected"),
[(498, 40), (499, 0), (500, 0), (0, 40)],
)
def test_delivery_charge(total, expected):
> assert delivery_charge(total) == expected
E assert 40 == 0
E + where 40 = delivery_charge(499)
test_shop.py:20: AssertionError
=========================== short test summary info ===========================
FAILED test_shop.py::test_delivery_charge[499-0] - assert 40 == 0
========================= 1 failed, 9 passed in 2.02s =========================The header lines, paths and time will differ on your computer. Each parametrised row has its own ID in square brackets, so you see at once that only 499 fails. The E lines show the actual value (40), the expected value (0) and the call that produced it.
Everyday command-line options
| Command | What it does |
|---|---|
pytest -v | Show each test name and result |
pytest -q | Quiet, short output |
pytest test_shop.py::test_cart_total | Run one test |
pytest -k cart | Run tests whose names contain "cart" |
pytest -m "not slow" | Skip tests marked slow |
pytest -x | Stop at the first failure |
pytest --lf | Rerun only the tests that failed last time |
pytest --junitxml=report.xml | Write a JUnit XML report for CI tools |
For example, pytest -q -m "not slow" on the project above ends with 1 failed, 8 passed, 1 deselected. Plugins add more: pytest-cov measures code coverage, pytest-xdist runs tests in parallel and pytest-playwright drives browsers (see Playwright).
Common mistakes
- Naming the file
shop_tests.py. pytest looks fortest_*.pyor*_test.py. - Importing a fixture from
conftest.py. Just name it as a test parameter; pytest finds it. - Sharing mutable state between tests. Use function-scoped fixtures (the default) for anything a test changes.
- Typos in marker names. Register markers in the configuration so a misspelt
@pytest.mark.slwois reported instead of silently ignored.
Key points
- pytest tests are plain functions in
test_*.pyfiles that use plainassert. - Fixtures prepare what tests need; put shared ones in
conftest.pyand request them by parameter name. @pytest.mark.parametrizeruns one test with many rows of data and reports each row.- Markers plus
-mand-kselect subsets such as smoke tests or "not slow". pytest.raiseschecks exceptions andpytest.approxcompares floats safely.
Exercise
Write a pytest test file for a function ticket_fare(km) that charges ₹10 for up to 2 km and ₹10 plus ₹5 for every extra km after that, and raises ValueError for 0 or negative distances. Use one parametrised test for the boundary values 1, 2 and 3 km and a second test for the error.
Show solution
# fares.py
def ticket_fare(km):
if km <= 0:
raise ValueError("distance must be positive")
if km <= 2:
return 10
return 10 + (km - 2) * 5# test_fares.py
import pytest
from fares import ticket_fare
@pytest.mark.parametrize(("km", "fare"), [(1, 10), (2, 10), (3, 15)])
def test_fare_boundaries(km, fare):
assert ticket_fare(km) == fare
@pytest.mark.parametrize("km", [0, -4])
def test_invalid_distance_rejected(km):
with pytest.raises(ValueError):
ticket_fare(km)Running pytest -v reports five results: three fare rows and two invalid distances. The rows 2 and 3 sit on either side of the 2 km boundary, where a < versus <= defect would appear.
Quick Quiz
1. How does a pytest test ask for a fixture called cart?
pytest matches parameter names to fixtures and passes in the prepared object.
2. What does pytest -m "not slow" do?
-m selects tests by marker expression.
3. Why use pytest.approx in assert 0.1 + 0.2 == pytest.approx(0.3)?
Binary floats cannot represent 0.1 exactly, so exact equality fails.
4. Which file name will pytest collect automatically?
pytest collects test_*.py and *_test.py files by default.
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
