Pytest has become the dominant Python testing framework, used by over 60% of Python developers according to the JetBrains 2024 Developer Ecosystem Survey. With pytest 8.4 dropping Python 3.8 support and pytest 9.0 landing in early 2026, the framework continues to evolve rapidly. This tutorial walks you through building a complete, production-ready test suite from scratch using pytest’s latest features, including fixtures, parametrization, mocking, async testing, and CI/CD integration.
Whether you are migrating from unittest, starting a greenfield project, or looking to improve your existing test coverage, this guide covers every step with working code examples you can copy and adapt. By the end, you will have a fully tested Python project with over 90% code coverage, parallel test execution, and a GitHub Actions pipeline that runs your suite on every push.
Prerequisites and Environment Setup
Before starting this pytest tutorial, make sure you have the following tools installed on your machine. Every command in this guide was tested on Ubuntu 24.04 LTS and macOS Sequoia, but works on any OS that supports Python.
Required software and versions:
- Python 3.10 or later – pytest 8.4+ requires Python 3.9 as a minimum, but 3.10+ is recommended for pattern matching and modern typing features. Check with
python3 --version. - pip 24.0+ – the latest pip ensures smooth dependency resolution. Upgrade with
python3 -m pip install --upgrade pip. - Git 2.40+ – needed for the CI/CD step. Verify with
git --version. - A code editor – VS Code with the Python extension or PyCharm Community both offer built-in pytest integration.
- A GitHub account – required for the GitHub Actions CI/CD pipeline in Step 12.
We will install pytest itself and all plugins inside a virtual environment in Step 1. No global installation is needed.
Step 1 – Install Pytest and Create the Project Structure
Every Python project should isolate its dependencies in a virtual environment. This prevents version conflicts between projects and ensures reproducible builds. Start by creating a new directory and initializing the environment.
# Create project directory
mkdir pytest-demo && cd pytest-demo
# Create and activate virtual environment
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venvScriptsactivate
# Install pytest and essential plugins
pip install pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
# Verify installation
pytest --version
# Output: pytest 8.4.2 (or later)
Now create a standard Python project layout. Separating source code from tests is a best practice enforced by most packaging tools including setuptools and hatch.
# Create project structure
mkdir -p src/app tests
# Create __init__.py files
touch src/__init__.py src/app/__init__.py tests/__init__.py
# Create pyproject.toml for project configuration
cat > pyproject.toml << 'EOF'
[project]
name = "pytest-demo"
version = "0.1.0"
requires-python = ">=3.10"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
markers = [
"slow: marks tests as slow (deselect with '-m "not slow"')",
"integration: marks integration tests",
]
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 80
EOF
The pyproject.toml file centralizes all pytest configuration. The addopts setting applies -v (verbose) and --tb=short (short tracebacks) to every test run automatically. Custom markers let you tag and filter tests by category.
Common Pitfall #1: Installing pytest globally instead of in a virtual environment. This causes version mismatches when different projects need different pytest versions. Always use python3 -m venv before installing.
Step 2 – Write Your First Test and Understand Test Discovery
Pytest discovers tests automatically by following three naming conventions: test files must start with test_ or end with _test.py, test functions must start with test_, and test classes must start with Test. No base class inheritance is required, unlike unittest.
First, create a simple module to test. This calculator.py module contains basic arithmetic operations that we will use throughout the tutorial.
# src/app/calculator.py
from typing import Union
Number = Union[int, float]
def add(a: Number, b: Number) -> Number:
"""Return the sum of two numbers."""
return a + b
def divide(a: Number, b: Number) -> float:
"""Return the quotient. Raises ValueError for zero divisor."""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def factorial(n: int) -> int:
"""Return n!. Raises ValueError for negative input."""
if not isinstance(n, int) or n < 0:
raise ValueError("Input must be a non-negative integer")
if n <= 1:
return 1
result = 1
for i in range(2, n + 1):
result *= i
return result
Now write tests for this module. Notice how pytest uses plain assert statements instead of special assertion methods like self.assertEqual().
# tests/test_calculator.py
import pytest
from src.app.calculator import add, divide, factorial
class TestAdd:
"""Tests for the add function."""
def test_add_positive_numbers(self):
assert add(2, 3) == 5
def test_add_negative_numbers(self):
assert add(-1, -1) == -2
def test_add_floats(self):
assert add(0.1, 0.2) == pytest.approx(0.3)
class TestDivide:
"""Tests for the divide function."""
def test_divide_integers(self):
assert divide(10, 2) == 5.0
def test_divide_by_zero_raises(self):
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
def test_divide_returns_float(self):
result = divide(7, 2)
assert isinstance(result, float)
class TestFactorial:
"""Tests for the factorial function."""
def test_factorial_zero(self):
assert factorial(0) == 1
def test_factorial_five(self):
assert factorial(5) == 120
def test_factorial_negative_raises(self):
with pytest.raises(ValueError, match="non-negative integer"):
factorial(-1)
Run the tests with a single command:
pytest
# Output:
# tests/test_calculator.py::TestAdd::test_add_positive_numbers PASSED
# tests/test_calculator.py::TestAdd::test_add_negative_numbers PASSED
# tests/test_calculator.py::TestAdd::test_add_floats PASSED
# tests/test_calculator.py::TestDivide::test_divide_integers PASSED
# tests/test_calculator.py::TestDivide::test_divide_by_zero_raises PASSED
# tests/test_calculator.py::TestDivide::test_divide_returns_float PASSED
# tests/test_calculator.py::TestFactorial::test_factorial_zero PASSED
# tests/test_calculator.py::TestFactorial::test_factorial_five PASSED
# tests/test_calculator.py::TestFactorial::test_factorial_negative_raises PASSED
# ========================= 9 passed in 0.03s =========================
The pytest.approx() helper handles floating-point comparison by allowing a small tolerance (1e-6 by default). The pytest.raises() context manager verifies that the correct exception type is raised and optionally checks the error message with a regex pattern via the match parameter.
Common Pitfall #2: Forgetting __init__.py files in your test and source directories. Without these, Python cannot resolve imports across directories, leading to ModuleNotFoundError. Always create them, even if they are empty.
Step 3 – Master Fixtures for Setup, Teardown, and Dependency Injection
Fixtures are the backbone of pytest. They replace the setUp() and tearDown() methods from unittest with a more flexible, composable system. A fixture is any function decorated with @pytest.fixture that provides data, configuration, or resources to your tests. Pytest injects fixtures automatically by matching the fixture name to the test function’s parameter names.
Let us build a more realistic example. Create a user service module that interacts with a data store.
# src/app/user_service.py
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class User:
username: str
email: str
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
active: bool = True
class UserStore:
"""In-memory user store for demonstration."""
def __init__(self):
self._users: dict[str, User] = {}
def add_user(self, user: User) -> User:
if user.username in self._users:
raise ValueError(f"User '{user.username}' already exists")
self._users[user.username] = user
return user
def get_user(self, username: str) -> User | None:
return self._users.get(username)
def delete_user(self, username: str) -> bool:
if username in self._users:
del self._users[username]
return True
return False
def list_users(self) -> list[User]:
return list(self._users.values())
@property
def count(self) -> int:
return len(self._users)
Now create fixtures in a conftest.py file. This special file is automatically loaded by pytest and makes fixtures available to all tests in the same directory and subdirectories.
# tests/conftest.py
import pytest
from src.app.user_service import User, UserStore
@pytest.fixture
def store():
"""Provide a fresh UserStore for each test."""
return UserStore()
@pytest.fixture
def sample_user():
"""Provide a sample user."""
return User(username="jdoe", email="[email protected]")
@pytest.fixture
def populated_store(store, sample_user):
"""Provide a store with one user already added."""
store.add_user(sample_user)
return store
Write tests that consume these fixtures:
# tests/test_user_service.py
import pytest
from src.app.user_service import User
class TestUserStore:
def test_add_user(self, store, sample_user):
result = store.add_user(sample_user)
assert result.username == "jdoe"
assert store.count == 1
def test_add_duplicate_raises(self, populated_store):
duplicate = User(username="jdoe", email="[email protected]")
with pytest.raises(ValueError, match="already exists"):
populated_store.add_user(duplicate)
def test_get_existing_user(self, populated_store):
user = populated_store.get_user("jdoe")
assert user is not None
assert user.email == "[email protected]"
def test_get_nonexistent_user(self, store):
assert store.get_user("nobody") is None
def test_delete_user(self, populated_store):
assert populated_store.delete_user("jdoe") is True
assert populated_store.count == 0
def test_delete_nonexistent_user(self, store):
assert store.delete_user("nobody") is False
def test_list_users(self, populated_store):
users = populated_store.list_users()
assert len(users) == 1
assert users[0].username == "jdoe"
Fixture scopes control how often a fixture is created. The default scope is function, meaning a new instance per test. Other scopes include class, module, and session. Use broader scopes for expensive resources like database connections:
@pytest.fixture(scope="session")
def db_connection():
"""Create a single database connection for the entire test session."""
conn = create_connection()
yield conn
conn.close() # Teardown: runs after all tests complete
The yield keyword splits a fixture into setup (before yield) and teardown (after yield). This pattern ensures resources are always cleaned up, even if a test fails.
Common Pitfall #3: Using session-scoped fixtures that hold mutable state shared between tests. This creates hidden dependencies between tests and causes flaky failures when test order changes. Keep mutable fixtures at function scope unless the resource is truly read-only.
Step 4 – Use Parametrize to Eliminate Repetitive Tests
The @pytest.mark.parametrize decorator runs the same test function with different input/output combinations. This eliminates copy-paste test duplication and makes it trivial to add new edge cases. Each parameter set generates an independent test with its own pass/fail result.
# tests/test_parametrize.py
import pytest
from src.app.calculator import add, divide, factorial
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
(0.1, 0.2, pytest.approx(0.3)),
(-5, -10, -15),
])
def test_add(a, b, expected):
assert add(a, b) == expected
@pytest.mark.parametrize("a, b, expected", [
(10, 2, 5.0),
(7, 2, 3.5),
(1, 3, pytest.approx(0.3333, rel=1e-3)),
(-10, 2, -5.0),
(0, 5, 0.0),
])
def test_divide(a, b, expected):
assert divide(a, b) == expected
@pytest.mark.parametrize("n, expected", [
(0, 1),
(1, 1),
(5, 120),
(10, 3628800),
(20, 2432902008176640000),
])
def test_factorial(n, expected):
assert factorial(n) == expected
@pytest.mark.parametrize("invalid_input", [-1, -100])
def test_factorial_rejects_negative(invalid_input):
with pytest.raises(ValueError):
factorial(invalid_input)
Running these tests shows each parameter combination as a separate test case:
pytest tests/test_parametrize.py -v
# Output:
# test_parametrize.py::test_add[1-2-3] PASSED
# test_parametrize.py::test_add[-1-1-0] PASSED
# test_parametrize.py::test_add[0-0-0] PASSED
# test_parametrize.py::test_add[100-200-300] PASSED
# test_parametrize.py::test_add[0.1-0.2-expected4] PASSED
# test_parametrize.py::test_add[-5--10--15] PASSED
# test_parametrize.py::test_divide[10-2-5.0] PASSED
# ...
# ========================= 17 passed in 0.04s =========================
You can also stack multiple @pytest.mark.parametrize decorators to create a cartesian product of test cases. This is useful for testing combinations of inputs:
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_add_combinations(x, y):
"""Generates 6 test cases: (1,10), (1,20), (2,10), (2,20), (3,10), (3,20)."""
result = add(x, y)
assert result == x + y
Common Pitfall #4: Using overly complex parametrize arguments that make test failures hard to debug. Each parameter set should have a clear, descriptive ID. Use the ids parameter or pytest.param() to add labels:
@pytest.mark.parametrize("a, b, expected", [
pytest.param(1, 2, 3, id="positive-ints"),
pytest.param(-1, -1, -2, id="negative-ints"),
pytest.param(0.1, 0.2, pytest.approx(0.3), id="floats"),
])
def test_add_with_ids(a, b, expected):
assert add(a, b) == expected
Step 5 – Mock External Dependencies with pytest-mock
Real applications call APIs, query databases, and read files. You should not hit external services in unit tests – they are slow, flaky, and may cost money. The pytest-mock plugin wraps Python’s unittest.mock library with a cleaner API via the mocker fixture.
Create a weather service that calls an external API:
# src/app/weather_service.py
import httpx
class WeatherService:
"""Fetches weather data from an external API."""
BASE_URL = "https://api.weatherapi.com/v1"
def __init__(self, api_key: str):
self.api_key = api_key
self.client = httpx.Client(timeout=10)
def get_temperature(self, city: str) -> float:
"""Return current temperature in Celsius for a city."""
response = self.client.get(
f"{self.BASE_URL}/current.json",
params={"key": self.api_key, "q": city},
)
response.raise_for_status()
data = response.json()
return data["current"]["temp_c"]
def is_freezing(self, city: str) -> bool:
"""Return True if temperature is at or below 0 degrees C."""
return self.get_temperature(city) <= 0.0
Now test it without making any real HTTP calls:
# tests/test_weather_service.py
import pytest
from src.app.weather_service import WeatherService
@pytest.fixture
def weather_service():
return WeatherService(api_key="test-key-123")
class TestWeatherService:
def test_get_temperature(self, weather_service, mocker):
# Mock the HTTP client's get method
mock_response = mocker.MagicMock()
mock_response.json.return_value = {
"current": {"temp_c": 22.5}
}
mocker.patch.object(
weather_service.client, "get", return_value=mock_response
)
temp = weather_service.get_temperature("London")
assert temp == 22.5
weather_service.client.get.assert_called_once()
def test_is_freezing_true(self, weather_service, mocker):
mocker.patch.object(
weather_service, "get_temperature", return_value=-5.0
)
assert weather_service.is_freezing("Moscow") is True
def test_is_freezing_false(self, weather_service, mocker):
mocker.patch.object(
weather_service, "get_temperature", return_value=15.0
)
assert weather_service.is_freezing("Paris") is False
def test_api_error_propagates(self, weather_service, mocker):
import httpx
mocker.patch.object(
weather_service.client, "get",
side_effect=httpx.HTTPStatusError(
"404", request=mocker.MagicMock(), response=mocker.MagicMock()
),
)
with pytest.raises(httpx.HTTPStatusError):
weather_service.get_temperature("Nowhere")
The mocker.patch.object() method replaces a specific attribute on an object for the duration of the test. It automatically restores the original after the test finishes. The side_effect parameter lets you simulate exceptions or return different values on consecutive calls.
Common Pitfall #5: Mocking too broadly. If you mock an entire module or class, your tests stop verifying real behavior. Mock only the external boundary – the HTTP call, the database query, the file read – and let everything else run for real.
Step 6 – Test Async Code with pytest-asyncio
Modern Python applications increasingly use async/await for I/O-bound operations. The pytest-asyncio plugin lets you write async test functions that pytest can discover and run. As of pytest 8.4, running async tests without a suitable plugin like pytest-asyncio causes an immediate failure rather than a silent warning.
Create an async data fetcher:
# src/app/async_fetcher.py
import asyncio
import httpx
async def fetch_json(url: str, timeout: float = 10.0) -> dict:
"""Fetch JSON from a URL asynchronously."""
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
async def fetch_multiple(urls: list[str]) -> list[dict]:
"""Fetch multiple URLs concurrently."""
tasks = [fetch_json(url) for url in urls]
return await asyncio.gather(*tasks)
Configure pytest-asyncio in your pyproject.toml and write async tests:
# Add to pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto"
# tests/test_async_fetcher.py
import pytest
from unittest.mock import AsyncMock
from src.app.async_fetcher import fetch_json, fetch_multiple
@pytest.mark.asyncio
async def test_fetch_json(mocker):
mock_response = mocker.MagicMock()
mock_response.json.return_value = {"status": "ok", "data": [1, 2, 3]}
mock_response.raise_for_status = mocker.MagicMock()
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mocker.patch("src.app.async_fetcher.httpx.AsyncClient", return_value=mock_client)
result = await fetch_json("https://api.example.com/data")
assert result == {"status": "ok", "data": [1, 2, 3]}
@pytest.mark.asyncio
async def test_fetch_json_timeout(mocker):
import httpx
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.ReadTimeout("Connection timed out")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mocker.patch("src.app.async_fetcher.httpx.AsyncClient", return_value=mock_client)
with pytest.raises(httpx.ReadTimeout):
await fetch_json("https://slow.example.com/data")
The asyncio_mode = "auto" setting in pyproject.toml automatically marks all async test functions for asyncio execution, so you can optionally omit the @pytest.mark.asyncio decorator. The AsyncMock class from unittest.mock handles await expressions transparently.
Step 7 – Measure and Enforce Code Coverage with pytest-cov
Code coverage tells you which lines, branches, and paths your tests actually exercise. The pytest-cov plugin integrates Coverage.py directly into your test runs, generating reports in terminal, HTML, or XML format.
Run your tests with coverage enabled:
pytest --cov=src --cov-report=term-missing --cov-report=html
# Output:
# ---------- coverage: platform linux, python 3.12.4 ----------
# Name Stmts Miss Branch BrPart Cover Missing
# -------------------------------------------------------------------------
# src/app/__init__.py 0 0 0 0 100%
# src/app/calculator.py 14 0 6 0 100%
# src/app/user_service.py 28 0 10 0 100%
# src/app/weather_service.py 15 0 2 0 100%
# src/app/async_fetcher.py 10 0 0 0 100%
# -------------------------------------------------------------------------
# TOTAL 67 0 18 0 100%
#
# ========================= 30 passed in 0.45s =========================
The --cov-report=html flag generates an interactive HTML report in the htmlcov/ directory. Open htmlcov/index.html in a browser to see line-by-line coverage with color-coded highlighting.
The fail_under = 80 setting in pyproject.toml makes the test run fail if total coverage drops below 80%. This enforces a minimum coverage standard in CI pipelines. Adjust the threshold based on your project’s needs – 80% is a practical minimum for most teams.
| Coverage Flag | Purpose | Example |
|---|---|---|
--cov=src | Measure coverage for specified source directory | pytest --cov=src |
--cov-report=term-missing | Show missing lines in terminal | Lists uncovered line numbers |
--cov-report=html | Generate HTML report | Browse htmlcov/index.html |
--cov-report=xml | Generate XML for CI tools | Consumed by Codecov, SonarQube |
--cov-branch | Enable branch coverage | Tests both if/else paths |
--cov-fail-under=80 | Fail if coverage below threshold | Enforces minimum in CI |
Common Pitfall #6: Chasing 100% coverage at the expense of test quality. Coverage measures which lines execute, not whether your assertions are meaningful. A test that calls a function without asserting the result achieves coverage but catches no bugs. Focus on testing behavior and edge cases, not hitting a number.
Step 8 – Run Tests in Parallel with pytest-xdist
As your test suite grows, sequential execution becomes a bottleneck. The pytest-xdist plugin distributes tests across multiple CPU cores, often cutting execution time by 50 to 80 percent on modern machines. This is especially impactful for I/O-bound integration tests.
# Run tests using all available CPU cores
pytest -n auto
# Run tests on exactly 4 workers
pytest -n 4
# Output:
# 4 workers [30 items]
# .......................... 30 passed in 0.12s
# (compared to 0.45s sequential)
The -n auto flag detects the number of available CPU cores and spawns that many worker processes. Each worker gets a subset of tests to run. Results are collected and reported as if the tests ran sequentially.
For parallel testing to work reliably, tests must be independent. They cannot share mutable state, write to the same file, or depend on execution order. If some tests do need isolation, use the --dist=loadgroup strategy with the @pytest.mark.xdist_group marker to keep related tests on the same worker.
| Distribution Mode | Behavior | Best For |
|---|---|---|
--dist=load (default) | Send tests to workers as they become free | General use, uneven test durations |
--dist=loadscope | Group tests by module/class, send groups to workers | Tests that share module-level fixtures |
--dist=loadgroup | Group tests by xdist_group marker | Tests that share external resources |
--dist=no | Disable parallel execution | Debugging test isolation issues |
Common Pitfall #7: Enabling parallel execution when tests write to the same database, file, or port. This causes random failures that are impossible to reproduce in sequential mode. Either isolate each worker with unique resources (separate database schemas, temp directories) or use --dist=loadscope to keep conflicting tests together.
Step 9 – Use Markers to Organize and Filter Tests
Markers let you categorize tests and selectively run subsets. This is essential in large projects where you want to run fast unit tests on every save but reserve slow integration tests for CI. Pytest includes several built-in markers and lets you define custom ones.
# tests/test_markers.py
import pytest
import time
@pytest.mark.slow
def test_heavy_computation():
"""Simulates a slow test."""
time.sleep(0.5)
assert sum(range(1_000_000)) == 499999500000
@pytest.mark.integration
def test_database_connection():
"""Would connect to a real database in production."""
assert True # Placeholder for actual DB test
@pytest.mark.skip(reason="Feature not implemented yet")
def test_future_feature():
assert False # Will not run
@pytest.mark.skipif(
condition=True, # Replace with actual condition
reason="Requires Linux",
)
def test_linux_only():
assert True
@pytest.mark.xfail(reason="Known bug, fix planned for next sprint")
def test_known_bug():
assert 1 + 1 == 3 # Expected to fail
Run specific marker groups from the command line:
# Run only fast tests (exclude slow and integration)
pytest -m "not slow and not integration"
# Run only integration tests
pytest -m integration
# Run everything except known failures
pytest -m "not xfail"
# List all available markers
pytest --markers
Register custom markers in pyproject.toml to avoid PytestUnknownMarkWarning warnings. The markers section we added in Step 1 already handles this. Unregistered markers still work but generate warnings that clutter your output.
| Marker | Purpose | Command-Line Filter |
|---|---|---|
@pytest.mark.skip | Always skip this test | -m "not skip" |
@pytest.mark.skipif | Skip conditionally | Automatic based on condition |
@pytest.mark.xfail | Expected failure (known bug) | -m "not xfail" |
@pytest.mark.parametrize | Run with multiple inputs | Runs all parameter sets |
@pytest.mark.slow | Custom: marks slow tests | -m "not slow" |
@pytest.mark.integration | Custom: integration tests | -m integration |
Step 10 – Test File Operations with tmp_path and monkeypatch
Pytest includes two powerful built-in fixtures for testing code that interacts with the filesystem and environment. The tmp_path fixture provides a unique temporary directory for each test (automatically cleaned up), while monkeypatch lets you modify environment variables, dictionary entries, and object attributes safely.
Create a configuration module that reads from files and environment variables:
# src/app/config.py
import json
import os
from pathlib import Path
def load_config(path: Path) -> dict:
"""Load JSON configuration from a file."""
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
with open(path) as f:
return json.load(f)
def get_database_url() -> str:
"""Get database URL from environment variable."""
url = os.environ.get("DATABASE_URL")
if not url:
raise RuntimeError("DATABASE_URL environment variable not set")
return url
def merge_configs(base: dict, override: dict) -> dict:
"""Deep merge two configuration dictionaries."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = merge_configs(result[key], value)
else:
result[key] = value
return result
Test it using tmp_path and monkeypatch:
# tests/test_config.py
import json
import pytest
from src.app.config import load_config, get_database_url, merge_configs
class TestLoadConfig:
def test_load_valid_config(self, tmp_path):
config_data = {"host": "localhost", "port": 5432, "debug": True}
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps(config_data))
result = load_config(config_file)
assert result == config_data
def test_load_missing_file(self, tmp_path):
missing = tmp_path / "missing.json"
with pytest.raises(FileNotFoundError, match="Config file not found"):
load_config(missing)
def test_load_invalid_json(self, tmp_path):
bad_file = tmp_path / "bad.json"
bad_file.write_text("not valid json{{{")
with pytest.raises(json.JSONDecodeError):
load_config(bad_file)
class TestGetDatabaseUrl:
def test_returns_url_when_set(self, monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/testdb")
assert get_database_url() == "postgresql://localhost/testdb"
def test_raises_when_not_set(self, monkeypatch):
monkeypatch.delenv("DATABASE_URL", raising=False)
with pytest.raises(RuntimeError, match="DATABASE_URL"):
get_database_url()
class TestMergeConfigs:
def test_shallow_merge(self):
base = {"a": 1, "b": 2}
override = {"b": 3, "c": 4}
assert merge_configs(base, override) == {"a": 1, "b": 3, "c": 4}
def test_deep_merge(self):
base = {"db": {"host": "localhost", "port": 5432}}
override = {"db": {"port": 3306}}
result = merge_configs(base, override)
assert result == {"db": {"host": "localhost", "port": 3306}}
The monkeypatch fixture is especially valuable for testing code that depends on environment variables, system properties, or module-level constants. All changes are automatically reverted when the test completes, preventing contamination between tests.
Step 11 – Build a Complete Working Project
Let us tie everything together by building a task manager module with full test coverage. This demonstrates how fixtures, parametrize, mocking, and markers work together in a real project.
# src/app/task_manager.py
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
class Priority(Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
@dataclass
class Task:
title: str
priority: Priority = Priority.MEDIUM
completed: bool = False
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def complete(self):
self.completed = True
def __post_init__(self):
if not self.title.strip():
raise ValueError("Task title cannot be empty")
class TaskManager:
def __init__(self):
self._tasks: list[Task] = []
def add_task(self, title: str, priority: Priority = Priority.MEDIUM) -> Task:
task = Task(title=title, priority=priority)
self._tasks.append(task)
return task
def complete_task(self, title: str) -> bool:
for task in self._tasks:
if task.title == title and not task.completed:
task.complete()
return True
return False
def get_pending(self) -> list[Task]:
return [t for t in self._tasks if not t.completed]
def get_by_priority(self, priority: Priority) -> list[Task]:
return [t for t in self._tasks if t.priority == priority]
def stats(self) -> dict:
total = len(self._tasks)
completed = sum(1 for t in self._tasks if t.completed)
return {
"total": total,
"completed": completed,
"pending": total - completed,
"completion_rate": (completed / total * 100) if total > 0 else 0.0,
}
The thorough test file:
# tests/test_task_manager.py
import pytest
from src.app.task_manager import Task, TaskManager, Priority
@pytest.fixture
def manager():
return TaskManager()
@pytest.fixture
def loaded_manager(manager):
manager.add_task("Write tests", Priority.HIGH)
manager.add_task("Fix bug", Priority.CRITICAL)
manager.add_task("Update docs", Priority.LOW)
manager.add_task("Code review", Priority.MEDIUM)
return manager
class TestTask:
def test_create_task(self):
task = Task(title="Test task")
assert task.title == "Test task"
assert task.priority == Priority.MEDIUM
assert task.completed is False
def test_empty_title_raises(self):
with pytest.raises(ValueError, match="cannot be empty"):
Task(title=" ")
def test_complete_task(self):
task = Task(title="Test")
task.complete()
assert task.completed is True
@pytest.mark.parametrize("priority", list(Priority))
def test_all_priorities(self, priority):
task = Task(title="Test", priority=priority)
assert task.priority == priority
class TestTaskManager:
def test_add_task(self, manager):
task = manager.add_task("New task")
assert task.title == "New task"
def test_complete_existing_task(self, loaded_manager):
assert loaded_manager.complete_task("Write tests") is True
def test_complete_nonexistent_task(self, loaded_manager):
assert loaded_manager.complete_task("Nonexistent") is False
def test_get_pending(self, loaded_manager):
loaded_manager.complete_task("Write tests")
pending = loaded_manager.get_pending()
assert len(pending) == 3
assert all(not t.completed for t in pending)
@pytest.mark.parametrize("priority, expected_count", [
(Priority.HIGH, 1),
(Priority.CRITICAL, 1),
(Priority.LOW, 1),
(Priority.MEDIUM, 1),
])
def test_get_by_priority(self, loaded_manager, priority, expected_count):
tasks = loaded_manager.get_by_priority(priority)
assert len(tasks) == expected_count
def test_stats_all_pending(self, loaded_manager):
stats = loaded_manager.stats()
assert stats["total"] == 4
assert stats["completed"] == 0
assert stats["pending"] == 4
assert stats["completion_rate"] == 0.0
def test_stats_after_completion(self, loaded_manager):
loaded_manager.complete_task("Write tests")
loaded_manager.complete_task("Fix bug")
stats = loaded_manager.stats()
assert stats["completed"] == 2
assert stats["completion_rate"] == 50.0
def test_stats_empty_manager(self, manager):
stats = manager.stats()
assert stats["total"] == 0
assert stats["completion_rate"] == 0.0
Run the full suite with coverage:
pytest --cov=src --cov-report=term-missing -v
# Expected output:
# 45+ tests passed
# Coverage: 95%+ across all modules
Step 12 – Set Up CI/CD with GitHub Actions
Automated testing on every push and pull request is the final piece of a professional testing workflow. GitHub Actions provides free CI/CD minutes for public repositories and 2,000 minutes per month for private repos on the free tier. Create a workflow file that runs your pytest suite across multiple Python versions.
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio httpx
- name: Run tests with coverage
run: |
pytest --cov=src --cov-report=xml --cov-fail-under=80 -n auto
- name: Upload coverage report
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
This workflow tests against Python 3.10 through 3.13, runs tests in parallel with -n auto, enforces 80% minimum coverage, and uploads the coverage report as an artifact. The matrix strategy catches compatibility issues early – a test might pass on Python 3.12 but fail on 3.10 due to syntax differences.
Commit and push to trigger the pipeline:
git init
git add .
git commit -m "Add pytest test suite with CI/CD"
git remote add origin https://github.com/YOUR_USERNAME/pytest-demo.git
git push -u origin main
Step 13 – Advanced Pytest Techniques
Once you master the basics, these advanced techniques will help you handle complex testing scenarios in production codebases.
Custom Fixture Factories
When you need many variations of the same object, a factory fixture avoids fixture explosion:
@pytest.fixture
def make_user():
"""Factory fixture for creating users with custom attributes."""
def _make_user(username="testuser", email="[email protected]", active=True):
return User(username=username, email=email, active=active)
return _make_user
def test_inactive_user(make_user):
user = make_user(username="inactive", active=False)
assert user.active is False
Testing Logging Output
The built-in caplog fixture captures log messages during a test:
import logging
def test_warning_logged(caplog):
logger = logging.getLogger("myapp")
with caplog.at_level(logging.WARNING):
logger.warning("Disk space low: 5% remaining")
assert "Disk space low" in caplog.text
assert caplog.records[0].levelname == "WARNING"
Capturing stdout and stderr
The capsys fixture captures print output:
def test_print_output(capsys):
print("Hello, pytest!")
captured = capsys.readouterr()
assert captured.out == "Hello, pytest!n"
assert captured.err == ""
Dynamic Test Generation with pytest_generate_tests
For cases where parametrize is not flexible enough, the pytest_generate_tests hook generates test cases programmatically:
# conftest.py
def pytest_generate_tests(metafunc):
if "csv_row" in metafunc.fixturenames:
import csv
with open("test_data.csv") as f:
reader = csv.DictReader(f)
rows = list(reader)
metafunc.parametrize("csv_row", rows, ids=[r["id"] for r in rows])
Troubleshooting: 10 Common Pytest Errors and Fixes
Even experienced developers run into these issues. Here are the most common pytest errors with their solutions.
1. ModuleNotFoundError: No module named ‘src’
This happens when Python cannot resolve your project imports. Fix it by installing your project in editable mode or by adding pythonpath to your pytest config:
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
2. PytestUnknownMarkWarning: Unknown pytest.mark.X
Register all custom markers in pyproject.toml under [tool.pytest.ini_options] markers. See Step 1 for the exact syntax.
3. fixture ‘X’ not found
The fixture is either misspelled, not imported, or defined in a conftest.py that is not in the test’s directory tree. Pytest only loads conftest.py files from the rootdir downward. Run pytest --fixtures to list all available fixtures and their locations.
4. ERRORS collecting test items – SyntaxError in test file
Pytest collects all test files before running any tests. A syntax error in any file stops the entire collection. Check the traceback for the exact file and line number. Use python -m py_compile tests/test_file.py to validate syntax independently.
5. Tests pass locally but fail in CI
Common causes: hardcoded file paths (use tmp_path), timezone assumptions (use UTC explicitly), missing environment variables (set them in your CI config), or OS-specific behavior (test on the same OS as CI). Run pytest -p no:randomly to check if test order matters.
6. xdist workers crashing with “internal error”
This usually means a fixture or test has side effects that conflict across processes. Disable xdist with pytest -n0 to confirm, then isolate the offending test with --dist=loadscope. Database fixtures often cause this when multiple workers try to write to the same schema.
7. pytest.raises does not catch the exception
The exception might be a different type than expected. Print the actual exception first: wrap the call in a try/except, or use pytest.raises(Exception) temporarily to see the actual type. Also verify the exception is raised inside the with block, not before it.
8. “async def test_…” silently skipped or failing
As of pytest 8.4, async tests without a plugin fail instead of being silently skipped. Install pytest-asyncio and either add @pytest.mark.asyncio or set asyncio_mode = "auto" in your config. See Step 6 for the full setup.
9. Coverage report shows 0% despite passing tests
The --cov source path must match your actual source directory. If your source is in src/app/, use --cov=src, not --cov=app. Also ensure pytest-cov is installed in the same virtual environment where you run pytest.
10. Slow test suite with no obvious bottleneck
Use pytest --durations=10 to identify the 10 slowest tests. Often a single test with a sleep, network call, or large dataset dominates the total time. Mark slow tests with @pytest.mark.slow and exclude them from rapid feedback loops with -m "not slow".
Pytest vs unittest vs nose2: Feature Comparison
If you are evaluating pytest against Python’s built-in unittest or the nose2 successor, this comparison covers the key differences. Pytest has emerged as the community standard, but understanding the alternatives helps you make an informed choice.
| Feature | pytest | unittest | nose2 |
|---|---|---|---|
| Test discovery | Automatic by naming convention | Requires TestCase subclass | Automatic (limited) |
| Assertion style | Plain assert with rewriting | self.assertEqual() methods | Plain assert |
| Fixtures | Dependency injection, scopes, yield | setUp() / tearDown() | Layers (limited) |
| Parametrization | Built-in @pytest.mark.parametrize | Requires subTest() | Plugin required |
| Plugin ecosystem | 1,300+ plugins on PyPI | None (standard library) | ~20 plugins |
| Parallel execution | pytest-xdist | Manual multiprocessing | nose2-multiprocess |
| Async support | pytest-asyncio | IsolatedAsyncioTestCase | Not supported |
| Community adoption | Dominant (60%+ of Python projects) | Built into standard library | Minimal |
| Learning curve | Low (plain functions and assert) | Medium (class-based, method names) | Low |
| Error output | Detailed diff with context | Basic assertion messages | Basic |
Pytest’s assertion rewriting is a standout feature. When a plain assert statement fails, pytest introspects the expression and displays a detailed diff showing exactly which values differed. This makes debugging failures faster than scanning through unittest’s verbose assertion method output.
Pytest Configuration Reference
Here is a complete reference for the most useful pytest configuration options. All settings go in pyproject.toml under [tool.pytest.ini_options].
[tool.pytest.ini_options]
# Test discovery paths
testpaths = ["tests"]
# Default command-line options
addopts = "-v --tb=short --strict-markers -n auto"
# Minimum Python version
minversion = "8.0"
# Register custom markers
markers = [
"slow: marks tests as slow",
"integration: marks integration tests",
"smoke: marks smoke tests for quick validation",
]
# Filter warnings
filterwarnings = [
"error", # Treat all warnings as errors
"ignore::DeprecationWarning", # Except deprecation warnings
]
# Async mode for pytest-asyncio
asyncio_mode = "auto"
# Python path for imports
pythonpath = ["."]
# Console output style (classic, progress, count)
console_output_style = "progress"
# Log settings
log_cli = true
log_cli_level = "WARNING"
These settings provide a production-ready baseline. The --strict-markers flag turns unknown marker warnings into errors, catching typos early. The filterwarnings section ensures your test suite is clean – treating warnings as errors forces you to address deprecations proactively.
Essential Pytest Plugins for Production
The pytest ecosystem includes over 1,300 plugins on PyPI. These are the ones used most in production environments, based on download statistics and community adoption.
| Plugin | Purpose | Install Command |
|---|---|---|
| pytest-cov | Code coverage measurement and reporting | pip install pytest-cov |
| pytest-xdist | Parallel test execution across CPU cores | pip install pytest-xdist |
| pytest-mock | Clean mocking API wrapping unittest.mock | pip install pytest-mock |
| pytest-asyncio | Async test function support | pip install pytest-asyncio |
| pytest-randomly | Randomize test order to catch hidden dependencies | pip install pytest-randomly |
| pytest-timeout | Enforce per-test time limits | pip install pytest-timeout |
| pytest-django | Django-specific fixtures and database handling | pip install pytest-django |
| pytest-httpx | Mock httpx requests in tests | pip install pytest-httpx |
| pytest-benchmark | Benchmark test performance with statistics | pip install pytest-benchmark |
| pytest-sugar | Better terminal progress bar and reporting | pip install pytest-sugar |
Install only the plugins you need. Each plugin adds overhead to test collection and startup. For most projects, pytest-cov, pytest-xdist, and pytest-mock cover 90% of testing needs.
Related Coverage
For more Python development tutorials and comparisons, see our related coverage:
- How to Master Playwright Testing: 13-Step Tutorial with 5 Projects [2026]
- How to Build a REST API with FastAPI: Complete Python Tutorial (2026)
- How to Build a Flask REST API in 12 Steps [2026]
- How to Build a REST API with Django REST Framework: Complete Tutorial (2026)
- How to Automate Tasks with Python: Complete Automation Tutorial (2026)
- How to Build a Task Queue with Celery Python and Redis in 13 Steps [2026]
- How to Build a CI/CD Pipeline with GitHub Actions: Complete Tutorial (2026)
FAQ
What is the difference between pytest and unittest?
Pytest uses plain assert statements and automatic test discovery, while unittest requires TestCase subclasses and special assertion methods like self.assertEqual(). Pytest’s fixture system is more flexible than unittest’s setUp/tearDown, supporting dependency injection, multiple scopes, and composition. Pytest also has a massive plugin ecosystem with over 1,300 plugins, while unittest ships only with what is in the standard library.
How do I run a single test file or function with pytest?
Run a single file with pytest tests/test_calculator.py. Run a specific function with pytest tests/test_calculator.py::test_add. Run a method inside a class with pytest tests/test_calculator.py::TestAdd::test_add_positive_numbers. Use -k for pattern matching: pytest -k "add and not negative" runs all tests containing “add” but not “negative” in their name.
How do I skip a test in pytest?
Use @pytest.mark.skip(reason="explanation") to always skip a test. Use @pytest.mark.skipif(condition, reason="explanation") to skip conditionally – for example, skipping a test on Windows when it requires Linux-specific features. You can also call pytest.skip("reason") inside a test function to skip at runtime based on dynamic conditions.
What is conftest.py and where should I put it?
conftest.py is a special pytest file for sharing fixtures, hooks, and plugins across test files. Place it in your tests/ directory to make fixtures available to all tests. You can have multiple conftest.py files in subdirectories for scoped sharing. Pytest automatically loads all conftest.py files from the root directory downward – no imports needed.
How do I test exceptions with pytest?
Use the pytest.raises(ExceptionType) context manager. Optionally add a match parameter with a regex pattern to verify the error message: with pytest.raises(ValueError, match="cannot be negative"). The context manager also provides the exception info object for further assertions: with pytest.raises(ValueError) as exc_info: ... assert "specific detail" in str(exc_info.value).
How do I measure code coverage with pytest?
Install pytest-cov and run pytest --cov=src --cov-report=term-missing. The --cov flag specifies which source directory to measure. Add --cov-report=html for a browsable HTML report. Set --cov-fail-under=80 to fail the test run if coverage drops below your threshold. Configure branch coverage in pyproject.toml with [tool.coverage.run] branch = true.
Can pytest run unittest tests?
Yes. Pytest is fully compatible with existing unittest test cases. It discovers and runs TestCase subclasses alongside native pytest tests. You can migrate incrementally – write new tests in pytest style while keeping old unittest tests running. Pytest’s output and features (like -k filtering and --durations) work on unittest tests too.
How do I run tests in parallel with pytest?
Install pytest-xdist and run pytest -n auto to use all available CPU cores, or pytest -n 4 for exactly 4 workers. Tests must be independent – no shared mutable state, no writing to the same files. Use --dist=loadscope to keep tests within the same module on the same worker if they share fixtures.
Sofia Lindström
Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.
View all articles