Pytest Tutorial for Beginners: Write and Run Your First Tests
A pytest test is a function whose name starts with test_, inside a file whose name starts with test_, that uses a plain assert. Save these five lines as test_calc.py:
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
Then run this from the same folder:
python -m pytest -q
. [100%]
1 passed in 0.02s
The dot is one passing test. That is pytest: no classes to inherit from, no special assert methods, just functions and assert. The rest of this tutorial covers how pytest finds tests, what a failure looks like, and the five features you will use in almost every test file.
Install pytest first
pytest is not part of the standard library, so install it with pip, ideally inside a virtual environment:
pip install pytest
pytest runs as a command on your own machine. It does not work inside PyRun's browser lessons or the Python terminal, because it needs to collect test files from a folder on disk. What you can do in the browser is write and try the function you are about to test, then copy it into a local file with its tests.
Prefer python -m pytest over the bare pytest command. Both run the same tool, but python -m also adds the current folder to Python's import path, which matters once your tests live in their own folder (more on that below).
How pytest finds your tests
pytest follows simple naming rules, called test discovery:
- It searches the current folder and all subfolders.
- It collects files named
test_*.pyor*_test.py. - Inside those files, it runs functions whose names start with
test. - It also runs
testmethods inside classes whose names start withTest(and that have no__init__method).
Anything else is ignored without a warning. A file called tests.py or a function called check_add will simply never run, and pytest will cheerfully report no tests ran. If your test count looks too low, check the names first.
Plain assert, readable failures
You write ordinary assert statements, and pytest rewrites them behind the scenes so that a failure shows the actual values involved. Here is a real bug: a discount function that uses floor division // where it should use /.
def apply_discount(price, percent):
return price - price * percent // 100
def test_discount():
assert apply_discount(999, 10) == 899.1
F [100%]
================================== FAILURES ===================================
________________________________ test_discount ________________________________
def test_discount():
> assert apply_discount(999, 10) == 899.1
E assert 900 == 899.1
E + where 900 = apply_discount(999, 10)
test_fail.py:5: AssertionError
=========================== short test summary info ===========================
FAILED test_fail.py::test_discount - assert 900 == 899.1
1 failed in 0.11s
The F marks the failure, the > points at the failing line, and the E lines show that the function returned 900. With plain Python you would only get a bare AssertionError. For lists, dicts and long strings, pytest goes further and prints which items differ. This one habit, reading the E lines before touching the code, is most of debugging a test; the debugging lesson covers the same skill for ordinary errors.
Test exceptions with pytest.raises
Good code rejects bad input, and you should test that it does. pytest.raises is a context manager: the test passes only if the code inside the with block raises that exception.
import pytest
def withdraw(balance, amount):
if amount > balance:
raise ValueError(f"insufficient balance: {balance}")
return balance - amount
def test_withdraw_ok():
assert withdraw(500, 200) == 300
def test_withdraw_too_much():
with pytest.raises(ValueError, match="insufficient balance"):
withdraw(500, 800)
.. [100%]
2 passed in 0.02s
The optional match argument also checks the error message. It is a regular expression searched in the message, so escape characters such as . or ( if you need them to match literally. If withdraw stopped raising, the test would fail with Failed: DID NOT RAISE.
Run one test with many inputs: parametrize
Copy-pasting the same test for five inputs is how test files get long and stale. @pytest.mark.parametrize runs one test function once per row of data:
import pytest
def is_palindrome(text):
cleaned = text.replace(" ", "").lower()
return cleaned == cleaned[::-1]
@pytest.mark.parametrize("text, expected", [
("madam", True),
("Nitin", True),
("never odd or even", True),
("python", False),
])
def test_is_palindrome(text, expected):
assert is_palindrome(text) == expected
.... [100%]
4 passed in 0.03s
The first argument names the parameters as a comma-separated string, and each tuple becomes a separate test. With -v you can see each case by its ID, so a failure tells you exactly which input broke:
test_params.py::test_is_palindrome[madam-True] PASSED [ 25%]
test_params.py::test_is_palindrome[Nitin-True] PASSED [ 50%]
test_params.py::test_is_palindrome[never odd or even-True] PASSED [ 75%]
test_params.py::test_is_palindrome[python-False] PASSED [100%]
Compare floats with pytest.approx
Floating-point numbers are stored in binary, so 0.1 + 0.2 is 0.30000000000000004, not 0.3. A plain == on floats will fail on results that are correct for all practical purposes. pytest.approx compares within a small tolerance (by default, a relative difference of one in a million):
import pytest
def test_plain_equality():
assert 0.1 + 0.2 == 0.3
def test_with_approx():
assert 0.1 + 0.2 == pytest.approx(0.3)
assert [0.1 + 0.2, 1 / 3] == pytest.approx([0.3, 0.3333333])
F. [100%]
================================== FAILURES ===================================
_____________________________ test_plain_equality _____________________________
def test_plain_equality():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
test_floats.py:4: AssertionError
=========================== short test summary info ===========================
FAILED test_floats.py::test_plain_equality - assert (0.1 + 0.2) == 0.3
1 failed, 1 passed in 0.09s
The first test fails on purpose; the second passes. approx also works on lists, tuples and dicts of numbers, as the second assert shows. Use it for any calculated float: averages, percentages, prices after tax.
Organise tests in a tests folder
Once you have more than one module, keep the code and tests apart:
project/
calc.py
tests/
conftest.py
test_calc.py
# calc.py
def add(a, b):
return a + b
def divide(a, b):
return a / b
# tests/test_calc.py
import pytest
from calc import add, divide
def test_add():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_divide():
assert divide(10, 4) == 2.5
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(1, 0)
Run python -m pytest from the project folder. Here the python -m form matters: the bare pytest command on this layout stops at collection with ModuleNotFoundError: No module named 'calc', because it does not put the project folder on the import path.
conftest.py is a special file pytest loads automatically before the tests in its folder. It is where you put shared setup, most often fixtures, that every test file in that folder can use without importing anything. It can be empty or absent until you need it.
Three flags you will use every day
-v (verbose) lists every test by name with its result:
tests/test_calc.py::test_add PASSED [ 25%]
tests/test_calc.py::test_add_negative PASSED [ 50%]
tests/test_calc.py::test_divide PASSED [ 75%]
tests/test_calc.py::test_divide_by_zero PASSED [100%]
-k runs only tests whose names match an expression. You can combine words with and, or and not:
python -m pytest -q -k divide
# 2 passed, 2 deselected
python -m pytest -q -k "add and not negative"
# 1 passed, 3 deselected
-x stops at the first failure, which keeps the output short when one broken function makes ten tests fail:
FAILED test_floats.py::test_plain_equality - assert (0.1 + 0.2) == 0.3
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed in 0.08s
The timings at the end of each run will differ on your machine.
Next: fixtures
The next thing you will want is shared setup: a sample list of students, a temporary file, a database connection that is created before a test and cleaned up after it. That is what pytest fixtures do. The pytest fixtures tutorial covers @pytest.fixture, yield teardown, scope and conftest.py in detail.
To practise, work through the pytest basics lesson and then the pytest fixtures lesson. They have runnable examples and graded exercises, and Python runs in your browser with no install. For writing and trying functions before you test them locally, use the practice editor.