Python pytest, explained
pytest is the de facto standard for testing Python. It wins over the stdlib unittest by
being far less boilerplate: plain assert statements, function-based tests, and a powerful
fixture system. Here are the essentials that cover the vast majority of real test suites.
Tests are just functions with assert
A pytest test is a function named test_* that uses a bare assert. pytest rewrites
assertions to show exactly what went wrong — no special assert methods needed.
# test_math.py
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
pytest # auto-discovers test_*.py and test_* functions
pytest -v # verbose, one line per test
pytest test_math.py::test_add # run one test
On failure pytest prints the actual values (assert 6 == 5), so you rarely need a custom
message.
Fixtures for setup and teardown
A fixture provides reusable setup. Declare it with @pytest.fixture and request it by
naming it as a test argument — pytest injects the return value. Anything after yield is
teardown.
import pytest
@pytest.fixture
def db():
conn = connect(":memory:") # setup
yield conn # value handed to the test
conn.close() # teardown, runs after the test
def test_insert(db): # pytest injects the fixture
db.execute("INSERT ...")
assert db.count() == 1
This replaces setUp/tearDown with composable, explicit dependencies — a test only gets
the fixtures it actually names.
Fixture scope
By default a fixture runs per test (function scope). For expensive setup (a database, a
server), widen the scope so it's created once and reused.
@pytest.fixture(scope="module") # created once per test module
def server():
s = start_server()
yield s
s.stop()
Scopes are function, class, module, package, and session. Put shared fixtures in a
conftest.py and pytest makes them available to all tests in that directory tree
automatically.
Parametrize for table-driven tests
@pytest.mark.parametrize runs the same test across many inputs, each reported as a separate
case — far better than a loop, which stops at the first failure.
import pytest
@pytest.mark.parametrize("value, expected", [
(2, 4),
(3, 9),
(-4, 16),
])
def test_square(value, expected):
assert value ** 2 == expected
Each tuple becomes an independent test, so you see all failures at once with the exact input that broke.
Testing that code raises
Use pytest.raises as a context manager to assert an exception is raised — and inspect it.
import pytest
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
1 / 0
def test_message():
with pytest.raises(ValueError, match="invalid"): # regex on the message
int("abc")
# or capture it:
with pytest.raises(ValueError) as exc:
raise ValueError("boom")
assert "boom" in str(exc.value)
The match= argument checks the exception message against a regex in one line.
Markers and useful flags
Markers tag tests for selection or special handling, and pytest has flags that speed up the edit-test loop.
import pytest
@pytest.mark.slow
def test_big_job(): ...
@pytest.mark.skip(reason="not implemented")
def test_future(): ...
@pytest.mark.xfail # expected to fail
def test_known_bug(): ...
pytest -k "add and not slow" # run by name expression
pytest -m slow # run only tests marked slow
pytest -x --lf # stop at first failure, rerun last-failed
Recap
pytest tests are plain test_* functions using bare assert with rich failure output.
Fixtures (@pytest.fixture, request by argument name, yield for teardown) provide
composable setup, and scope plus conftest.py control sharing. Use
@pytest.mark.parametrize for table-driven tests that report every case independently,
and pytest.raises (with match=) to assert and inspect exceptions. Markers and flags
like -k, -x, and --lf make running the right tests fast.