VOOZH about

URL: https://tech-insider.org/pytest-tutorial-python-testing-ci-cd-2026/

⇱ How to Master Pytest: 13-Step Tutorial with CI/CD [2026]


Skip to content
April 16, 2026
27 min read

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.

👁 Step 1 — Install Pytest and Create the Project Structure
# 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.

👁 Step 4 — Use Parametrize to Eliminate Repetitive Tests
# 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.

👁 Step 7 — Measure and Enforce Code Coverage with pytest-cov

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 FlagPurposeExample
--cov=srcMeasure coverage for specified source directorypytest --cov=src
--cov-report=term-missingShow missing lines in terminalLists uncovered line numbers
--cov-report=htmlGenerate HTML reportBrowse htmlcov/index.html
--cov-report=xmlGenerate XML for CI toolsConsumed by Codecov, SonarQube
--cov-branchEnable branch coverageTests both if/else paths
--cov-fail-under=80Fail if coverage below thresholdEnforces 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 ModeBehaviorBest For
--dist=load (default)Send tests to workers as they become freeGeneral use, uneven test durations
--dist=loadscopeGroup tests by module/class, send groups to workersTests that share module-level fixtures
--dist=loadgroupGroup tests by xdist_group markerTests that share external resources
--dist=noDisable parallel executionDebugging 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.

MarkerPurposeCommand-Line Filter
@pytest.mark.skipAlways skip this test-m "not skip"
@pytest.mark.skipifSkip conditionallyAutomatic based on condition
@pytest.mark.xfailExpected failure (known bug)-m "not xfail"
@pytest.mark.parametrizeRun with multiple inputsRuns all parameter sets
@pytest.mark.slowCustom: marks slow tests-m "not slow"
@pytest.mark.integrationCustom: 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.

👁 Step 10 — Test File Operations with tmp_path and monkeypatch

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.

👁 Step 13 — Advanced Pytest Techniques

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.

Featurepytestunittestnose2
Test discoveryAutomatic by naming conventionRequires TestCase subclassAutomatic (limited)
Assertion stylePlain assert with rewritingself.assertEqual() methodsPlain assert
FixturesDependency injection, scopes, yieldsetUp() / tearDown()Layers (limited)
ParametrizationBuilt-in @pytest.mark.parametrizeRequires subTest()Plugin required
Plugin ecosystem1,300+ plugins on PyPINone (standard library)~20 plugins
Parallel executionpytest-xdistManual multiprocessingnose2-multiprocess
Async supportpytest-asyncioIsolatedAsyncioTestCaseNot supported
Community adoptionDominant (60%+ of Python projects)Built into standard libraryMinimal
Learning curveLow (plain functions and assert)Medium (class-based, method names)Low
Error outputDetailed diff with contextBasic assertion messagesBasic

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.

PluginPurposeInstall Command
pytest-covCode coverage measurement and reportingpip install pytest-cov
pytest-xdistParallel test execution across CPU corespip install pytest-xdist
pytest-mockClean mocking API wrapping unittest.mockpip install pytest-mock
pytest-asyncioAsync test function supportpip install pytest-asyncio
pytest-randomlyRandomize test order to catch hidden dependenciespip install pytest-randomly
pytest-timeoutEnforce per-test time limitspip install pytest-timeout
pytest-djangoDjango-specific fixtures and database handlingpip install pytest-django
pytest-httpxMock httpx requests in testspip install pytest-httpx
pytest-benchmarkBenchmark test performance with statisticspip install pytest-benchmark
pytest-sugarBetter terminal progress bar and reportingpip 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:

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

Editor-in-Chief

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
👁 Tech Insider
Tech
Insider

Tech Insider delivers in-depth coverage of the technologies shaping the future: AI, cybersecurity, cloud computing, hardware, and the trends that matter.

Company

Explore

Categories

© 2026 Tech Insider Media AB. All rights reserved.