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 (or setup.cfg) at the root. If you only have requirements.txt, the steps still work; substitute pip install commands 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/activate

Steps

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-asyncio

Install the extras:

pip install -e ".[dev]"

2. Create the test directory and conftest.py

mkdir -p tests
touch tests/__init__.py tests/conftest.py

conftest.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-missing

myapp 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. Run pip install -e . to install it in editable mode.
  • Fixtures are not found. The conftest.py is in the wrong directory. Place it at the root of the tests/ directory or in a parent directory that pytest searches.
  • asyncio_mode warning. You have async tests but did not install pytest-asyncio, or the asyncio_mode = "auto" setting is missing from pyproject.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 --cov argument points to the wrong package name. Match it to the directory name under src/ or the top-level package.