pytest tutorial for beginners: one function, six tests, real output

To start with pytest you need three things: a file whose name starts with test_, a function whose name starts with test, and a plain assert. That's the whole API for your first month. The reason to install pytest is what it prints when a test fails: the values on both sides of the comparison, without you asking. Below are six tests for two small functions, and one of them catches a bug you won't spot by reading the code.

Install it and write the first test

pytest is a third-party package, so it goes in with pip — in the same environment as the code you're testing:

$ python -m pip install pytest
$ python -m pytest --version
pytest 8.4.2

The code under test is the total_of function from the git walkthrough, which skips anything that isn't a number, plus a new one that takes a percentage off a price. It goes in prices.py:

def total_of(prices):
    total = 0
    for price in prices:
        if not isinstance(price, (int, float)):
            continue
        total = total + price
    return total


def apply_discount(price, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return price * percent / 100

Next to it, test_prices.py:

from prices import apply_discount, total_of


def test_total_of_sums_numbers():
    assert total_of([2.5, 1.5, 6]) == 10


def test_total_of_empty_list_is_zero():
    assert total_of([]) == 0


def test_apply_discount_takes_percent_off():
    assert apply_discount(200, 25) == 150

No class, no self, no assertEqual. A test is a function that either finishes quietly or raises — and a failed assert raises.

Read the failure report, top to bottom

$ pytest -q
..F                                                                      [100%]
================================== FAILURES ===================================
____________________ test_apply_discount_takes_percent_off ____________________

    def test_apply_discount_takes_percent_off():
>       assert apply_discount(200, 25) == 150
E       assert 50.0 == 150
E        +  where 50.0 = apply_discount(200, 25)

test_prices.py:13: AssertionError
=========================== short test summary info ===========================
FAILED test_prices.py::test_apply_discount_takes_percent_off - assert 50.0 ==...
1 failed, 2 passed in 0.10s

The first line is one character per test: a dot for a pass, F for a failure. Then the failure itself. The > marks the line that failed. The E lines are the useful part: the function returned 50.0, the test expected 150, and pytest shows you the call that produced it.

You wrote a bare assert and still got both values. pytest rewrites assert statements in test files before running them, so it can report what each side evaluated to. Plain Python would give you AssertionError and nothing else.

The bug: price * percent / 100 is the discount, not the price after it. Twenty-five percent of 200 is 50. The fix is price * (100 - percent) / 100, and the run goes green:

$ pytest -q
...                                                                      [100%]
3 passed in 0.01s

One test, many cases: parametrize

Two near-identical tests for total_of is where copy-paste starts. @pytest.mark.parametrize turns one test function into one test per row of data, so adding a case costs one line. While writing the rows, ask what else a caller could pass. A string is covered. What about a boolean?

import pytest


@pytest.mark.parametrize(
    "prices, expected",
    [
        ([2.5, 1.5, 6], 10),
        ([], 0),
        ([3, "free", 4], 7),
        ([5, True], 5),
    ],
)
def test_total_of(prices, expected):
    assert total_of(prices) == expected
$ pytest -q
...F.                                                                    [100%]
================================== FAILURES ===================================
__________________________ test_total_of[prices3-5] ___________________________

prices = [5, True], expected = 5
...
>       assert total_of(prices) == expected
E       assert 6 == 5
E        +  where 6 = total_of([5, True])

test_prices.py:16: AssertionError

That's the bug no amount of rereading found. In Python, bool is a subclass of int, so isinstance(True, int) is True and True gets added as 1. If a form somewhere sends a checkbox value into your price list, the total is quietly off by one. The fix is to rule booleans out first:

        if isinstance(price, bool) or not isinstance(price, (int, float)):
            continue

Note the test ID, test_total_of[prices3-5]: pytest names each case so you can tell which row broke. prices3 is the fourth row, counting from zero.

Testing that something fails

A percentage of 150 should be refused, not turned into a negative price. pytest.raises passes only if the code inside the block raises the exception you named:

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

match checks the message as a regular expression, so the test also fails if some unrelated ValueError fires for the wrong reason. All six tests:

$ pytest -q
......                                                                   [100%]
6 passed in 0.01s

The flags you'll use in the first month

CommandWhat it does
pytest -qShort output: dots, failures, one summary line
pytest -vOne line per test with its name and result
pytest -xStop at the first failure
pytest -k total_ofRun only the tests whose names match the expression
pytest test_prices.py::test_total_ofRun one test function from one file
$ pytest -v -k total_of
collecting ... collected 6 items / 2 deselected / 4 selected

test_prices.py::test_total_of[prices0-10] PASSED                         [ 25%]
test_prices.py::test_total_of[prices1-0] PASSED                          [ 50%]
test_prices.py::test_total_of[prices2-7] PASSED                          [ 75%]
test_prices.py::test_total_of[prices3-5] PASSED                          [100%]

======================= 4 passed, 2 deselected in 0.02s =======================

The most common beginner failure isn't a failure

$ pytest -q

no tests ran in 0.00s

That's what you get when pytest found nothing to run. By default it collects files named test_*.py or *_test.py, and inside them functions whose names start with test. Name the file tests_prices.py, or the function check_total, and pytest skips it without a word of complaint. The exit code is 5, not 0, which is why a CI pipeline treats "no tests" as a problem and not as a pass.

Where this goes next

Green tests only tell you the cases you thought of work. The boolean row exists because someone asked "what else could come in?" — and a mutation tester asks a related question about your tests themselves: if I break this line, does anything go red?

That's also how ProdQuest grades. Your solution runs against pytest tests, including hidden ones you don't see, alongside lint, mutation tests, and security checks — together at least 70% of the grade. The editor, terminal, and Python run in the browser, so there's nothing to install. If you'd rather practice on tasks picked for your level than invent your own, try the first task as a guest. If you're still before your first function, start with the first-week plan.

FAQ

Is pytest better than unittest?

For a beginner, it's shorter. unittest ships with Python and wants a class and methods like self.assertEqual; pytest takes plain functions and a bare assert. pytest also runs existing unittest tests, so picking it doesn't lock you out of either style.

Why does pytest say "no tests ran"?

It didn't find any. Check that the file is named test_*.py or *_test.py, that test functions start with test, and that you're running pytest from the folder that contains them.

How do I run a single test?

Give the path and the name: pytest test_prices.py::test_total_of. To run tests by part of their name, use pytest -k total_of.

Do I need classes to write pytest tests?

No. A test is a function whose name starts with test. You can group tests in a class whose name starts with Test, but nothing requires it.