Overview
pytest is the de-facto test runner for Python. A clean setup uses pyproject.toml for configuration, a root-level conftest.py for shared fixtures, and pytest-cov for coverage reports. This guide walks from zero to a passing test suite with coverage in 15 minutes. The broader testing philosophy lives in testing.
Prerequisites
- Python 3.11 or newer.
- A project with a
pyproject.toml(orsetup.cfg) at the root. If you only haverequirements.txt, the steps still work; substitutepip installcommands for the[project.optional-dependencies]block. - A virtual environment activated. Never install test dependencies into the system Python.
python -m venv .venv && source .venv/bin/activateSteps
1. Add test dependencies to pyproject.toml
Declare test dependencies in the [project.optional-dependencies] table so they are installable with a single command and kept out of the production install.
[project.optional-dependencies]
dev = [
"pytest>=8.2",
"pytest-cov>=5.0",
"pytest-asyncio>=0.23", # only if you test async code
]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
asyncio_mode = "auto" # only if using pytest-asyncioInstall the extras:
pip install -e ".[dev]"2. Create the test directory and conftest.py
mkdir -p tests
touch tests/__init__.py tests/conftest.pyconftest.py is where pytest looks for fixtures that are shared across multiple test files. It is auto-loaded; you do not import it.
# tests/conftest.py
import pytest
@pytest.fixture
def sample_user() -> dict:
return {"id": 1, "name": "Alice", "email": "alice@example.com"}3. Write the first test
Create tests/test_users.py. Name test files test_*.py and test functions test_*; pytest discovers them automatically.
# tests/test_users.py
from myapp.users import format_display_name
def test_format_display_name_returns_full_name(sample_user):
result = format_display_name(sample_user)
assert result == "Alice"
def test_format_display_name_with_missing_name():
user = {"id": 2, "email": "bob@example.com"}
result = format_display_name(user)
assert result == "bob@example.com"Use the sample_user fixture from conftest.py by declaring it as a parameter. pytest injects it automatically.
4. Use fixtures for shared setup and teardown
Fixtures replace setUp / tearDown from unittest. They are more composable and their scope is explicit.
# tests/conftest.py
import pytest
from myapp.db import get_test_db, teardown_test_db
@pytest.fixture(scope="session")
def db():
"""Create a test database once per test session."""
conn = get_test_db()
yield conn
teardown_test_db(conn)
@pytest.fixture
def db_transaction(db):
"""Wrap each test in a rolled-back transaction."""
with db.begin() as tx:
yield tx
tx.rollback()scope="session" creates the fixture once and reuses it across all tests. The default scope is function: a new instance per test. See set-up-postgres-locally for the Postgres connection setup that get_test_db() would wrap.
5. Verify collection with --collect-only
Before running, confirm pytest finds exactly the tests you expect:
pytest --collect-only
# <Module tests/test_users.py>
# <Function test_format_display_name_returns_full_name>
# <Function test_format_display_name_with_missing_name>If a test is missing, the file is not named test_*.py, or the function does not start with test_.
6. Run with coverage
pytest --cov=myapp --cov-report=term-missingmyapp is the package under test. term-missing shows which lines are not covered.
---------- coverage: platform linux, python 3.12 ----------
Name Stmts Miss Cover Missing
-----------------------------------------------------
myapp/users.py 18 2 89% 34-35
-----------------------------------------------------
TOTAL 18 2 89%
Add --cov-fail-under=80 to CI to enforce a minimum coverage threshold.
Verify it worked
# 1. pytest discovers and passes all tests.
pytest -v
# ...
# 2 passed in 0.15s
# 2. Coverage report generates without error.
pytest --cov=myapp --cov-report=term-missing
# 3. Collection finds all test files.
pytest --collect-only | grep "<Module"Common errors
ModuleNotFoundError: No module named 'myapp'. The package is not installed. Runpip install -e .to install it in editable mode.- Fixtures are not found. The
conftest.pyis in the wrong directory. Place it at the root of thetests/directory or in a parent directory that pytest searches. asyncio_modewarning. You have async tests but did not installpytest-asyncio, or theasyncio_mode = "auto"setting is missing frompyproject.toml.- Tests pass locally but fail in CI. Environment variables are missing. Add them to the CI configuration. See bash-one-liners for a quick env check pattern.
- Coverage is 0% despite tests running. The
--covargument points to the wrong package name. Match it to the directory name undersrc/or the top-level package.