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.
- 1Encounter
- 2Understand
- 3Worked
- 4Predict
- 5Apply
- 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:
def discounted(price, percent):
return price - price * percent / 100
print(discounted(200, 10))
print(discounted(80, 25))
print(discounted(99, 0))180.0
60.0
99.0You 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:
def discounted(price, percent):
return price * (1 - percent // 100)$ python -c "from prices import discounted; print(discounted(200, 10))"
200It 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:
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:
$ python check_prices.py
all goodWith the "tidied" one:
$ 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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorThe 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
uvorpip, and confirm it withpytest --version - Write a test file that pytest finds on its own, and run it
- Read the dots and the summary line, and switch between
-qand-v - Say what exit codes
0and1mean, 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:
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 testsWhy 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:
$ python -c "from prices import discounted; print(discounted(200, 10))"
180.0If 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:
$ 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.1uv records it in pyproject.toml under a dev group, so anyone who sets the project up later gets the same tool:
[dependency-groups]
dev = [
"pytest>=9.1.1",
]If you use plain pip inside a virtual environment, activate the environment and run:
$ python -m pip install pytestpip prints its download progress and ends with:
Successfully installed iniconfig-2.3.1 packaging-26.3 pluggy-1.6.0 pygments-2.21.0 pytest-9.1.1python -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:
$ pytest --version
pytest 9.1.1One 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:
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) == 99Each 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:
$ 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:
$ 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.01sThe 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.0shows 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:
$ pytest -q
... [100%]
3 passed in 0.01s-v (verbose) gives each test its own line, with its full name:
$ 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:
$ pytest -q
... [100%]
3 passed in 0.01s
$ echo $?
0And with the broken function, after the run that ended in 2 failed, 1 passed:
$ echo $?
10— everything collected, everything passed1— 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:
shop/
├── prices.py
└── test_prices.pyThat 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:
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:
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"$ 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:
$ 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.01sThe 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 ...:
$ 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.01sNote 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:
from prices import discounted
def test_quarter_off():
discounted(80, 25) == 999$ pytest -q
. [100%]
1 passed in 0.01sThe 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?
Step 4 of 6 — Predict
Check your understanding
This file is saved as test_double.py and pytest -q is run. Which characters start the first line of the output?
def double(n):
return n * 2
def test_two():
assert double(2) == 4
def test_zero():
assert double(0) == 1
def test_negative():
assert double(-3) == -6- AF..
- B..F
- C.F.
- DF
999 is plainly the wrong answer, yet pytest reports 1 passed. Why?
def discounted(price, percent):
return price - price * percent / 100
def test_quarter_off():
discounted(80, 25) == 999- Apytest only checks that the function ran, not its result
- BDecimal numbers cannot be compared with `==`
- CThe function name does not start with `test_`
- DThe line has no `assert` — the comparison is computed and thrown away
A file with correct tests in it is named double_checks.py. What happens when you run pytest in that folder?
- AEvery test runs, because the function names start with `test_`
- B`no tests ran` — the file name does not start with `test_`, so it is never searched; exit code `5`
- Cpytest stops with an `ImportError`
- Dpytest renames the file to `test_double_checks.py` for you
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
Create a folder with a file temperature.py holding two functions:
c_to_f(celsius)— returnscelsius * 9 / 5 + 32f_to_c(fahrenheit)— returns(fahrenheit - 32) * 5 / 9
Then install pytest into that folder's environment and confirm it with pytest --version.
Write test_temperature.py with at least five tests. Include water freezing (0 and 32), water boiling (100 and 212), and -40, which is the same in both scales. Give every test a name that says what it checks.
Run it three ways — pytest, pytest -q and pytest -v — and after one of them, check the exit code.
Then break it on purpose: change 9 / 5 to 5 / 9 in c_to_f. Before running, predict which tests will fail and what the progress line will look like. Run pytest -q, compare, and check the exit code again.
Last, rename test_temperature.py to temperature_check.py and run pytest. Read what it says, and its exit code. Then name it back. Knowing what "found nothing" looks like will save you a confused afternoon.
Solution
First the plan, before any code — the same table as before:
| Case | Input | Expected | | --- | --- | --- | | Water freezes | c_to_f(0), f_to_c(32) | 32, 0 | | Water boils | c_to_f(100), f_to_c(212) | 212, 100 | | The two scales meet | c_to_f(-40), f_to_c(-40) | -40, -40 |
temperature.py:
def c_to_f(celsius):
return celsius * 9 / 5 + 32
def f_to_c(fahrenheit):
return (fahrenheit - 32) * 5 / 9Before writing tests, the import check from the project folder:
$ python -c "import temperature; print('importable')"
importabletest_temperature.py:
from temperature import c_to_f, f_to_c
def test_water_freezes_at_32_f():
assert c_to_f(0) == 32
def test_water_boils_at_212_f():
assert c_to_f(100) == 212
def test_32_f_is_zero_c():
assert f_to_c(32) == 0
def test_212_f_is_100_c():
assert f_to_c(212) == 100
def test_minus_40_is_the_same_in_both_scales():
assert c_to_f(-40) == -40
assert f_to_c(-40) == -40$ pytest -q
..... [100%]
5 passed in 0.01s
$ echo $?
0
$ 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/temperature
collecting ... collected 5 items
test_temperature.py::test_water_freezes_at_32_f PASSED [ 20%]
test_temperature.py::test_water_boils_at_212_f PASSED [ 40%]
test_temperature.py::test_32_f_is_zero_c PASSED [ 60%]
test_temperature.py::test_212_f_is_100_c PASSED [ 80%]
test_temperature.py::test_minus_40_is_the_same_in_both_scales PASSED [100%]
============================== 5 passed in 0.01s ===============================Now the deliberate bug, 9 / 5 changed to 5 / 9 in c_to_f:
$ pytest -q
.F..F [100%]
=================================== FAILURES ===================================
__________________________ test_water_boils_at_212_f ___________________________
def test_water_boils_at_212_f():
> assert c_to_f(100) == 212
E assert 87.55555555555556 == 212
E + where 87.55555555555556 = c_to_f(100)
test_temperature.py:9: AssertionError
___________________ test_minus_40_is_the_same_in_both_scales ___________________
def test_minus_40_is_the_same_in_both_scales():
> assert c_to_f(-40) == -40
E assert 9.777777777777779 == -40
E + where 9.777777777777779 = c_to_f(-40)
test_temperature.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_temperature.py::test_water_boils_at_212_f - assert 87.55555555555...
FAILED test_temperature.py::test_minus_40_is_the_same_in_both_scales - assert...
2 failed, 3 passed in 0.01s
$ echo $?
1And the renamed file:
$ pytest -q
no tests ran in 0.01s
$ echo $?
5Why it is written this way:
- One behaviour per test, named as a sentence. In the
-vlisting, each line says what is promised. Whentest_water_boils_at_212_ffails, you know what broke before you read any further. - Known, checkable values.
0/32,100/212and-40are facts you can verify without a calculator, so a failure points at the code, not at a doubtful expected value. - Both directions.
c_to_fandf_to_care separate code, so each needs its own tests; one being right says nothing about the other. - The bug run is the most telling part. Only two tests failed, and
test_water_freezes_at_32_fwas not one of them:0 * 5 / 9 + 32is still32, because multiplying zero hides any multiplier. That is exactly why the plan has more than one case — a suite with only the freezing point would have passed with the bug in place. The-40test has twoassertlines; the first one failed, so the second never ran. That is acceptable here because both describe the same promise. - The exit codes —
0,1,5— are the three you will meet most: all passed, something failed, nothing found.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz