Chapter 01

Installing pytest and your first test

Why checking by hand stops working, why a bare assert tells you almost nothing, and how to install pytest and write and run a first test file. Including the dots, the summary line, -q, -v and exit codes.

40 minPython 3.12
  1. 1Encounter
  2. 2Understand
  3. 3Worked
  4. 4Predict
  5. 5Apply
  6. 6Stretch

The problem we are solving

Here is a small function, and the way most of us check a function like it — run it and look at what comes out:

python
def discounted(price, percent):
    return price - price * percent / 100


print(discounted(200, 10))
print(discounted(80, 25))
print(discounted(99, 0))
text
180.0
60.0
99.0

You read the three numbers, do the sums in your head, and decide they are right. That is a test. It is a manual one, and it has two problems.

The first is that it does not scale. Three numbers are easy to check by eye. Thirty functions with five cases each, checked again after every change, are not — and nobody does it.

The second is worse. A month later someone tidies the function up:

python
def discounted(price, percent):
    return price * (1 - percent // 100)
text
$ python -c "from prices import discounted; print(discounted(200, 10))"
200

It looks reasonable, and it runs without an error. But // is whole-number division, so 10 // 100 is 0, and every discount has quietly vanished. Code that used to work and no longer does is called a regression, and a regression is exactly what manual checking misses — because nobody goes back to re-check the old numbers.

The fix is to write the expected answers down in code, so that a machine can check them every time. Python already has a statement for that, assert. Put the checks in a file called check_prices.py, next to prices.py:

python
from prices import discounted

assert discounted(200, 10) == 180.0
assert discounted(80, 25) == 60.0
assert discounted(99, 0) == 99
print("all good")

With the original function:

text
$ python check_prices.py
all good

With the "tidied" one:

text
$ python check_prices.py
Traceback (most recent call last):
  File "/home/you/shop/check_prices.py", line 3, in <module>
    assert discounted(200, 10) == 180.0
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

The regression is caught, which is progress. But look at what you are told: AssertionError, and nothing else. Not what discounted(200, 10) actually returned. And the script stopped at the first failure, so you do not know whether the other two checks pass.

This chapter installs the tool that fixes both: pytest.

By the end of this chapter you can

  • Explain why automated tests catch regressions that manual checking misses
  • Install pytest with uv or pip, and confirm it with pytest --version
  • Write a test file that pytest finds on its own, and run it
  • Read the dots and the summary line, and switch between -q and -v
  • Say what exit codes 0 and 1 mean, and why that matters

Prerequisites: before you write a test. You also need Python 3 and to know how to write a function.


Before you write the test

The previous chapter was about deciding what to test. This one is about the concrete things that have to be in place before the first line of test code can run. There are four, and each one, when missing, produces its own confusing error — so it pays to check them in order.

1. One project folder, one environment. Everything in this chapter lives in a single folder:

text
shop/
├── .venv/            the project's own Python environment
├── pyproject.toml    created by uv; lists pytest as a dev dependency
├── prices.py         the code under test
└── test_prices.py    the tests

Why an environment of its own? Because pytest has to be installed into the same Python that will import your code. If pytest lives in one Python and your project's packages in another, you get errors that look like missing code when the real problem is a missing tool. A .venv per project removes the guesswork.

2. pytest installed in that environment — the next section does this.

3. The code importable. A test file is ordinary Python: its first line will be from prices import discounted. So before writing any test, check that this exact import works from the project folder:

text
$ python -c "from prices import discounted; print(discounted(200, 10))"
180.0

If this line fails, pytest will fail the same way — only wrapped in a longer report. Checking it on its own takes five seconds and separates "my code cannot be found" from "my code is wrong". It also means the module name must be a valid Python name: prices.py can be imported, my-prices.py and 2prices.py cannot.

4. The promise, written down as cases. Before typing a test, write down what discounted promises, one row per case:

| Case | Input | Expected | | --- | --- | --- | | Typical discount | discounted(200, 10) | 180.0 | | A second, different discount | discounted(80, 25) | 60.0 | | Edge: no discount at all | discounted(99, 0) | 99 |

And just as deliberately, what is not on the list. A negative percentage, or one above 100: the function makes no promise about those yet, and a test would only freeze whatever it happens to do today. (Rejecting bad input with an error is a promise of its own — chapter four.) Python's own arithmetic, too: you are testing your formula, not that * multiplies.

Why two ordinary cases and not one? Look at the third row against the "tidied" function from the start of the chapter: 99 * (1 - 0 // 100) is still 99. The zero-percent case passes even with the bug. A plan made only of edge cases can miss the very mistake that matters, and a plan made of one typical case misses the edges. Each row is there to catch something the others would not.

The rest of the chapter turns this table into a test file.

Installing pytest

pytest is not part of Python; it is a package you install into your project. It is a tool for developing the code, not something the program needs when it runs — so it goes in as a development dependency.

If your project uses uv, run this in the project folder:

text
$ uv add --dev pytest
Resolved 7 packages in 59ms
Installed 5 packages in 18ms
 + iniconfig==2.3.1
 + packaging==26.3
 + pluggy==1.6.0
 + pygments==2.21.0
 + pytest==9.1.1

uv records it in pyproject.toml under a dev group, so anyone who sets the project up later gets the same tool:

toml
[dependency-groups]
dev = [
    "pytest>=9.1.1",
]

If you use plain pip inside a virtual environment, activate the environment and run:

text
$ python -m pip install pytest

pip prints its download progress and ends with:

text
Successfully installed iniconfig-2.3.1 packaging-26.3 pluggy-1.6.0 pygments-2.21.0 pytest-9.1.1

python -m pip rather than bare pip makes sure the package lands in the same Python you will run — a common source of "I installed it but it's not there".

Now check that it worked:

text
$ pytest --version
pytest 9.1.1

One note before going on. With uv, commands run inside the project's environment when prefixed with uv run — so uv run pytest --version. With an activated virtual environment, plain pytest is enough. The rest of this course writes plain pytest; add uv run in front if that is how you work.

Your first test file

Two naming rules, and pytest needs nothing else from you:

  • the file name starts with test_ (or ends with _test.py)
  • each function you want run starts with test

Put the original, correct prices.py back, and next to it create test_prices.py:

python
from prices import discounted


def test_ten_percent_off():
    assert discounted(200, 10) == 180.0


def test_quarter_off():
    assert discounted(80, 25) == 60.0


def test_no_discount():
    assert discounted(99, 0) == 99

Each check from check_prices.py is now its own small function, and each name says what is being checked. There is no print, no list of tests to register, no if __name__ == "__main__". Now run pytest from the project folder:

text
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 3 items

test_prices.py ...                                                       [100%]

============================== 3 passed in 0.01s ===============================

pytest looked through the folder, found test_prices.py because of its name, found the three functions because of theirs, ran each one, and reported.

Reading the output

Take it one line at a time.

  • platform ... — which Python and which pytest ran. Worth a glance when results differ between two machines.
  • rootdir: /home/you/shop — the folder pytest treats as the project's top.
  • collected 3 items — how many tests it found. Finding tests is called collection. If this number is lower than you expect, a name is wrong somewhere.
  • test_prices.py ... — one character per test, in the order they ran. A . means passed.
  • [100%] — progress through the whole run.
  • 3 passed in 0.01s — the summary line. This is the line to read first.

Now put the "tidied" discounted back and run again. This time with -q, which we will meet properly in a moment:

text
$ pytest -q
FF.                                                                      [100%]
=================================== FAILURES ===================================
_____________________________ test_ten_percent_off _____________________________

    def test_ten_percent_off():
>       assert discounted(200, 10) == 180.0
E       assert 200 == 180.0
E        +  where 200 = discounted(200, 10)

test_prices.py:5: AssertionError
_______________________________ test_quarter_off _______________________________

    def test_quarter_off():
>       assert discounted(80, 25) == 60.0
E       assert 80 == 60.0
E        +  where 80 = discounted(80, 25)

test_prices.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_prices.py::test_ten_percent_off - assert 200 == 180.0
FAILED test_prices.py::test_quarter_off - assert 80 == 60.0
2 failed, 1 passed in 0.01s

The progress line is now FF.: an F for each failure. And compare this with the bare AssertionError from before:

  • every test ran — a failure in one did not stop the others, so you can see that zero percent still works
  • each failure names the test and the line
  • assert 200 == 180.0 shows the value the function actually returned

Reading this report in full is the whole of the next chapter. For now, notice only that the summary line changed to 2 failed, 1 passed — and that the same assert you already knew now explains itself.

-q and -v: less and more

The default output is a middle ground. Two flags move it either way.

-q (quiet) drops the header:

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

-v (verbose) gives each test its own line, with its full name:

text
$ pytest -v
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /home/you/shop
collecting ... collected 3 items

test_prices.py::test_ten_percent_off PASSED                              [ 33%]
test_prices.py::test_quarter_off PASSED                                  [ 66%]
test_prices.py::test_no_discount PASSED                                  [100%]

============================== 3 passed in 0.01s ===============================

test_prices.py::test_ten_percent_off is the test's node ID: the file, two colons, the function. You will use it later to run a single test. This is also where descriptive test names pay off — in a -v listing, test_quarter_off tells you what passed; test_2 would not.

Use -q when you run tests constantly and only want the verdict, and -v when you want to see exactly which tests ran.

What is .pytest_cache? A folder pytest creates to remember things between runs, such as which tests failed last time. It is safe to delete and does not belong in version control.

Exit codes: how a machine reads the result

You read the summary line. A script, an editor or a build server cannot — it reads the exit code, the number every program hands back to the shell when it finishes. In a Unix shell, $? holds the last one:

text
$ pytest -q
...                                                                      [100%]
3 passed in 0.01s
$ echo $?
0

And with the broken function, after the run that ended in 2 failed, 1 passed:

text
$ echo $?
1
  • 0 — everything collected, everything passed
  • 1 — tests ran, and at least one failed

(In PowerShell the variable is $LASTEXITCODE.) This is what lets tests run automatically before code is merged: a non-zero exit code stops the process. There are a few more codes, one of which you will meet in "When it breaks" below.

Where test files live

In this chapter the test file sits right next to the code:

text
shop/
├── prices.py
└── test_prices.py

That is fine for a handful of files. Most projects instead gather tests in a tests/ folder of their own. Either way pytest finds them by name; how to lay out a real project, and what changes when you do, is chapter three.


A complete example

A function that turns a score into a letter grade, grades.py:

python
def letter_grade(score):
    """Turn a score from 0 to 100 into a letter."""
    if score >= 80:
        return "A"
    if score >= 60:
        return "B"
    if score >= 40:
        return "C"
    return "F"

And its tests, test_grades.py:

python
from grades import letter_grade


def test_top_score_is_an_a():
    assert letter_grade(95) == "A"


def test_exactly_eighty_is_still_an_a():
    assert letter_grade(80) == "A"


def test_just_below_eighty_is_a_b():
    assert letter_grade(79) == "B"


def test_middle_score_is_a_c():
    assert letter_grade(50) == "C"


def test_zero_is_an_f():
    assert letter_grade(0) == "F"
text
$ pytest -v
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /home/you/grades
collecting ... collected 5 items

test_grades.py::test_top_score_is_an_a PASSED                            [ 20%]
test_grades.py::test_exactly_eighty_is_still_an_a PASSED                 [ 40%]
test_grades.py::test_just_below_eighty_is_a_b PASSED                     [ 60%]
test_grades.py::test_middle_score_is_a_c PASSED                          [ 80%]
test_grades.py::test_zero_is_an_f PASSED                                 [100%]

============================== 5 passed in 0.01s ===============================

Two things are worth noticing.

First, the tests at 80 and 79. These sit on either side of a boundary, which is where mistakes live. Change score >= 80 to score > 80 — an easy slip — and run the quiet form:

text
$ pytest -q
.F...                                                                    [100%]
=================================== FAILURES ===================================
______________________ test_exactly_eighty_is_still_an_a _______________________

    def test_exactly_eighty_is_still_an_a():
>       assert letter_grade(80) == "A"
E       AssertionError: assert 'B' == 'A'
E         
E         - A
E         + B

test_grades.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_grades.py::test_exactly_eighty_is_still_an_a - AssertionError: as...
1 failed, 4 passed in 0.01s

The second test, test_exactly_eighty_is_still_an_a, is the one that fails: 80 now gets a 'B'. A test at 95 alone would never have noticed.

Second, the names. In the -v listing each line reads as a sentence about the function's behaviour. When one fails months from now, the name alone tells you which promise was broken.


When it breaks

zsh: command not found: pytest (or bash: pytest: command not found) The shell cannot find a pytest program. Either it is not installed, or it is installed in a virtual environment that is not active. With uv, run uv run pytest; with pip, activate the environment first. python -m pytest also works, and runs pytest with whichever Python python is.

/usr/bin/python3: No module named pytest You ran python -m pytest, and that Python does not have pytest. The path at the start tells you which Python it was — here the system one, not your project's. Install pytest into the environment you mean to use.

ModuleNotFoundError: No module named 'price' during collection The test file could not import your code — here because of a typo, from price import ... instead of from prices import ...:

text
$ pytest -q
==================================== ERRORS ====================================
_______________________ ERROR collecting test_prices.py ________________________
ImportError while importing test module '/home/you/shop/test_prices.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_prices.py:1: in <module>
    from price import discounted
E   ModuleNotFoundError: No module named 'price'
=========================== short test summary info ============================
ERROR test_prices.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s

Note the word ERROR, not FAILED: no test ran at all, because the file could not even be loaded, and the exit code is 2. Read the last E line, then run the import check from "Before you write the test" — python -c "from prices import discounted" — until it works on its own.

no tests ran in 0.01s pytest ran, but collected nothing. The file name does not start with test_ (tests_prices.py, prices_tests.py and test.py are all missed), or the functions do not start with test (check_quarter_off is ignored silently). The exit code here is 5, not 0, so an automated run does not mistake "found nothing" for "all passed".

A test passes when it obviously should not Look for a missing assert:

python
from prices import discounted


def test_quarter_off():
    discounted(80, 25) == 999
text
$ pytest -q
.                                                                        [100%]
1 passed in 0.01s

The comparison is computed and thrown away. A test fails only when something inside it raises; with no assert, nothing does.

PytestReturnNotNoneWarning: Test functions should return None You wrote return discounted(99, 0) == 99 instead of assert .... pytest ignores what a test returns, so this test passes whether the comparison is true or false. The warning even asks: Did you mean to use assert instead of return?