---
title: "How to set up pytest"
slug: "set-up-pytest"
category: "howto"
tags: ["howto", "pytest", "python", "testing", "tutorial", "backend"]
status: "stable"
last_updated: 2026-05-14
summary: "Add pytest and pytest-cov to pyproject.toml, write a conftest.py, create the first test, use fixtures, and verify collection with --collect-only."
related: ["[[coding/python]]", "[[coding/testing]]", "[[backend/fastapi]]", "[[coding/general-principles]]", "[[howto/set-up-postgres-locally]]", "[[backend/postgres]]", "[[cheatsheets/bash-one-liners]]"]
---

## 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 [[coding/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.

```bash
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.

```toml
[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:

```bash
pip install -e ".[dev]"
```

### 2. Create the test directory and `conftest.py`

```bash
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.

```python
# 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.

```python
# 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.

```python
# 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 [[howto/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:

```bash
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

```bash
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

```bash
# 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 [[cheatsheets/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.

## Related

- [[coding/python]]
- [[coding/testing]]
- [[backend/fastapi]]
- [[coding/general-principles]]
- [[howto/set-up-postgres-locally]]
- [[backend/postgres]]
- [[cheatsheets/bash-one-liners]]
