Tests are the safety net that lets you refactor a function without holding your breath. pytest is the standard, friendly way to write them in Python: there's no test class to subclass and no ceremony — a test is just a plain function whose name starts with test_, and you check expectations with the built-in assert statement.
Install
pytest is a regular package, so install it from PyPI — preferably inside a virtual environment:
pip install -U pytest
Confirm it's there:
pytest --version
Write your first test
A real project keeps code and tests close together. Create test_sample.py with a function to test and a test for it:
def func(x):
return x + 1
def test_answer():
assert func(3) == 5
The second function is the test. Naming matters because pytest discovers tests by name: it collects files named test_*.py or *_test.py, and inside them, functions named test_*. Now run it from that directory:
pytest
You'll see the result — in this case one test passes. Now change the assertion to == 4, which is true, and run again.
Assert with plain Python
Nothing special is involved in assert func(3) == 5 — it's the ordinary Python assertion. What pytest adds is advanced assertion introspection: when an assertion fails, it rewrites the message to show the intermediate values involved, so you learn why it failed instead of just that it failed. There's no JUnit-style assertEqual to remember.
Running a single file narrows the noise:
pytest test_sample.py
Test many inputs with parametrize
Copy-pasting a test to check one more input gets stale fast. @pytest.mark.parametrize runs the same test body once per input set:
import pytest
def add(a, b):
return a + b
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(2, 3, 5),
(5, 3, 18),
])
def test_add(a, b, expected):
assert add(a, b) == expected
pytest runs test_add three times — the decorator's second argument is a list of (a, b, expected) triples used in turn — and reports each as its own test. The third case is deliberately wrong, so you'll see exactly one failure: the introspection shows that add(5, 3) returned 8, not 18. That's the workflow: one test body, many inputs, each failure attributable to its exact case.
Expect an exception with pytest.raises
Testing that something should throw is a test too. pytest.raises asserts that an exception of a given type is raised:
import pytest
def divide(a, b):
return a / b
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(1, 0)
The with block must raise a ZeroDivisionError (or a subclass) — if it doesn't, the test fails. You can also match on the message with pytest.raises(ValueError, match="must be 0"), which is handy for asserting the reason a function refuses bad input, not just that it refused.
What's next
Run pytest in watch mode while you work, look at fixtures (@pytest.fixture) for setting up shared state, and explore --tb=short or -x to stop at the first failure during debugging. Once a codebase has tests, the bigger win is using a coverage tool to see which lines are actually exercised — that's where untested paths hide.