pytest is the gold standard for Python testing in 2026. Used by Django, FastAPI, NumPy, and thousands of open-source projects, pytest makes writing and running tests intuitive and powerful. This complete guide takes you from your first test to advanced fixtures, parametrize, and CI integration.
Why pytest over unittest?
- Simpler syntax — plain
assertstatements with helpful failure messages - Powerful fixtures — dependency injection with automatic teardown
- Parametrize — run one test with many inputs
- Rich plugin ecosystem — coverage, xdist, mock, asyncio, and more
- Better discovery — finds tests automatically without boilerplate
Installation and First Test
# Install pytest
pip install pytest pytest-cov pytest-asyncio
# Create a test
# test_math.py
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
# Run tests
pytest # run all tests
pytest test_math.py # specific file
pytest -v # verbose output
pytest -k "add" # run tests matching name
pytest --tb=short # shorter tracebacks
Test Organization
# Recommended structure
# tests/
# conftest.py — shared fixtures
# test_users.py
# test_orders.py
# integration/
# test_api.py
# unit/
# test_services.py
# pytest.ini or pyproject.toml
# [tool.pytest.ini_options]
# testpaths = ["tests"]
# addopts = "-v --tb=short"
# Test class grouping
class TestUserService:
def test_create_user(self):
user = create_user("alice@example.com")
assert user.email == "alice@example.com"
def test_create_user_invalid_email(self):
with pytest.raises(ValueError, match="Invalid email"):
create_user("not-an-email")
def test_duplicate_email_raises(self, db):
create_user("alice@example.com")
with pytest.raises(DuplicateError):
create_user("alice@example.com")
Fixtures — pytest’s Superpower
import pytest
from app.database import Database
from app.models import User
# Simple fixture
@pytest.fixture
def sample_user():
return User(id=1, name="Alice", email="alice@example.com")
# Fixture with setup and teardown
@pytest.fixture
def db():
database = Database(url="sqlite:///:memory:")
database.create_tables()
yield database # test runs here
database.drop_tables() # teardown
# Session-scoped fixture — runs once per test session
@pytest.fixture(scope="session")
def app_client():
from app import create_app
app = create_app(testing=True)
return app.test_client()
# Fixture using other fixtures
@pytest.fixture
def admin_user(db):
user = db.create_user(email="admin@test.com", role="admin")
return user
# conftest.py — shared fixtures auto-discovered
# pytest finds conftest.py automatically
Parametrize — Test Multiple Cases
import pytest
def is_valid_email(email: str) -> bool:
import re
return bool(re.match(r"[^@]+@[^@]+.[^@]+", email))
@pytest.mark.parametrize("email,expected", [
("alice@example.com", True),
("user+tag@domain.co.uk", True),
("invalid", False),
("no-at-sign", False),
("@nodomain.com", False),
("user@.com", False),
("", False),
])
def test_email_validation(email, expected):
assert is_valid_email(email) == expected
# Multiple parametrize decorators — creates cartesian product
@pytest.mark.parametrize("base", [2, 10, 16])
@pytest.mark.parametrize("exp", [1, 2, 3])
def test_powers(base, exp):
result = base ** exp
assert result == pow(base, exp)
Mocking with pytest-mock
pip install pytest-mock
import pytest
from app.services import UserService
from app.email import EmailSender
def test_registration_sends_welcome_email(mocker):
# Mock the email sender
mock_send = mocker.patch("app.services.EmailSender.send")
service = UserService()
service.register("alice@example.com", "securepass")
# Assert email was sent
mock_send.assert_called_once_with(
to="alice@example.com",
subject="Welcome to TechPulse!"
)
def test_external_api_timeout(mocker):
# Mock requests.get to simulate timeout
mocker.patch(
"requests.get",
side_effect=requests.exceptions.Timeout
)
with pytest.raises(ServiceUnavailableError):
fetch_weather("London")
# Spy on real method (calls real code but tracks calls)
def test_cache_used_on_second_call(mocker):
spy = mocker.spy(cache_service, "get")
fetch_user(1)
fetch_user(1) # should hit cache
assert spy.call_count == 2
Testing Async Code
pip install pytest-asyncio
import pytest
import pytest_asyncio
# Mark entire file as async
# pytest.ini: asyncio_mode = auto
@pytest.mark.asyncio
async def test_async_fetch():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get("/api/users")
assert response.status_code == 200
# Async fixture
@pytest_asyncio.fixture
async def async_db():
async with AsyncDatabase() as db:
await db.create_tables()
yield db
await db.drop_tables()
Coverage Reporting
# Run with coverage
pytest --cov=app --cov-report=html --cov-report=term-missing
# Output:
# Name Stmts Miss Cover
# app/models.py 45 3 93%
# app/services.py 87 12 86%
# TOTAL 132 15 89%
# Fail if coverage below threshold
pytest --cov=app --cov-fail-under=80
# .coveragerc or pyproject.toml
# [tool.coverage.run]
# omit = ["*/tests/*", "*/migrations/*"]
pytest Marks — Categorize Tests
import pytest
# Define marks in pyproject.toml
# [tool.pytest.ini_options]
# markers = [
# "slow: marks tests as slow",
# "integration: marks integration tests",
# "unit: marks unit tests",
# ]
@pytest.mark.slow
@pytest.mark.integration
def test_full_user_registration_flow(client, db):
# End-to-end registration test
response = client.post("/register", json={
"email": "test@example.com",
"password": "SecurePass123!"
})
assert response.status_code == 201
assert db.users.count() == 1
# Run only unit tests
# pytest -m unit
# Skip slow tests in CI
# pytest -m "not slow"
GitHub Actions CI Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install uv
uv pip install -e ".[dev]" --system
- name: Run tests
run: pytest --cov=app --cov-fail-under=80
- name: Upload coverage
uses: codecov/codecov-action@v4
pytest mastery takes practice but pays dividends. Write tests alongside features (TDD), use fixtures for setup/teardown, parametrize for edge cases, and mock external dependencies. Aim for 80%+ coverage on business logic and 100% on critical paths.
✍️ Leave a Comment