EthicsComputerCourses
Join training +91 90532 10052

Testing with pytest

Software Testing · Lesson 16 of 30 Intermediate 7 min read Updated

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:

Terminal
python -m pip install pytest
pytest --version

pytest 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:

Python
# 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:

PythonRun it yourself
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")
Output
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.

Python
# 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
Python
# 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:

Python
@pytest.mark.parametrize(
    ("total", "expected"),
    [(498, 40), (499, 0), (500, 0), (0, 40)],
)
def test_delivery_charge(total, expected):
    assert delivery_charge(total) == expected

Markers 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):

TOML
[tool.pytest]
markers = ["slow: tests that take a long time"]
Python
@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:

Output
============================= 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

CommandWhat it does
pytest -vShow each test name and result
pytest -qQuiet, short output
pytest test_shop.py::test_cart_totalRun one test
pytest -k cartRun tests whose names contain "cart"
pytest -m "not slow"Skip tests marked slow
pytest -xStop at the first failure
pytest --lfRerun only the tests that failed last time
pytest --junitxml=report.xmlWrite 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 for test_*.py or *_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.slwo is reported instead of silently ignored.

Key points

  • pytest tests are plain functions in test_*.py files that use plain assert.
  • Fixtures prepare what tests need; put shared ones in conftest.py and request them by parameter name.
  • @pytest.mark.parametrize runs one test with many rows of data and reports each row.
  • Markers plus -m and -k select subsets such as smoke tests or "not slow".
  • pytest.raises checks exceptions and pytest.approx compares 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
Python
# 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
Python
# 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?

2. What does pytest -m "not slow" do?

3. Why use pytest.approx in assert 0.1 + 0.2 == pytest.approx(0.3)?

4. Which file name will pytest collect automatically?

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