How to Master Pytest: 13-Step Tutorial with CI/CD and 90% Coverage [2026]

Pytest has become the dominant Python testing framework, used by over 60% of Python developers according to the JetBrains 2024 Developer Ecosystem Survey, and a January 2026 Programming-helper analysis calls it the backbone of Python testing inside a test-automation market projected to reach $60 billion by 2029 – demand reflected in LinkedIn Learning’s 10.15-hour “Playwright Python and Pytest” course, released February 27, 2026. With pytest 9.1.1 – released by the pytest-dev team in June 2026 and still the version shown on the official documentation’s “Get Started” page as of August 2026 – now shipping on PyPI alongside a full slate of documentation, tutorials, and PDFs that PyPI itself listed as of June 2026, and the official pytest documentation refreshed as recently as August 17, 2026, the framework continues to evolve rapidly; pytest’s own release announcements catalog nine releases spanning the June 2025 8.4.1 update – which added the --force-short-summary flag – through the June 2026 9.1.0 and 9.1.1 point releases, as of August 2026, underscoring just how fast that evolution has been. 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 – if you are coming from an existing unittest suite, a 2026 migration guide from Q&A Skills lays out 10 discrete upgrade steps for moving to pytest 9, and the walkthrough below follows that same general arc. 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.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Prerequisites and Environment Setup

Before starting this pytest tutorial, make sure you have the following tools installed on your machine. Note that pytest’s own official release page shows a steady march of point releases behind that number – 8.3.5 shipped in March 2025 with Python 3.13 and libedit compatibility fixes, 8.4.2 followed by September 2025, and a 9.0.2 point release landed in December 2025 – and the documentation’s full “Contents” page – refreshed again by September 2026, when both the official “Get Started” guide and the pytest 9.1.1 documentation still listed Python 3.8+ as the supported floor – still points readers to the same “How-to guides” tutorials, but this guide targets the newer 9.x release line described below – if you want a shorter primer first, Microsoft’s own “Testing with Pytest” module went live on Microsoft Learn on May 27, 2025 and covers the same basics. 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 – a lineup of five packages (pytest, pytest-cov, pytest-xdist, pytest-mock, and pytest-asyncio) that a 2026 tutorial recommends installing together for exactly this kind of full-featured suite. No global installation is needed – for reference, the official pytest “Get Started” guide, current as of September 2026, still walks through this same core workflow using pip install -U pytest, though this guide targets the newer 9.1.1 release that shipped in June 2026 as pytest-dev’s latest PyPI listing – a 386.5-kB wheel among just two release files.

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) – and pytest-dev’s own April 2026 9.0.3 release shipped a fix for how pytest.approx() handled mapping-key order during comparisons, so it is worth upgrading past 9.0.2 if you rely on this helper for dict-based assertions. 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.2 – the version shown on the official release page as of September 2025 – running async tests without a suitable plugin like pytest-asyncio causes an immediate failure rather than a silent warning, a behavior that carried through pytest 9.0.0 – released in November 2025 and the same release that added subtest support – and 9.0.1, also released that November and notable for restoring unittest.SkipTest support, and into the documentation’s 2026 announcement index, which lists both 8.4.2 and 9.1.1 as current entries.

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 – echoing the advanced fixture, plugin, and JSON-report patterns MarkTechPost profiled in its pytest deep-dive published October 14, 2025.

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 – Landbase’s technology-adoption tracking counted 1,471 companies actively using pytest-based testing tools and tutorials as of August 2025, and Udemy alone lists pytest courses priced from $19.99 to $89.99 as of June 2025, including a $27.99 beginner tutorial covering 2 hours of content – 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. Note that the console_output_style = "progress" option traces back to the live terminal progress indicator pytest 9.0.0 introduced in November 2025 – pytest-dev then disabled it by default in the 9.0.2 release that December 2025, so set this option explicitly if you want the progress bar back.

Essential Pytest Plugins for Production

The pytest ecosystem includes over 1,300 plugins on PyPI, and community plugin listings – including the “simplify pytest fixtures” entry updated June 19, 2026 – now flag a pytest 8+ requirement as the baseline. 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 below – and if you would rather learn pytest through a structured course, DataCamp’s “Introduction to Testing in Python” (updated June 11, 2025) awards 750 XP, Talk Python Training’s “Pragmatic pytest” runs $49 for 3.5 hours as of March 2026, Shiksha lists “Python Automation Testing with Pytest” at ₹389 for 7 hours of training (updated October 2025), The AI Horizon published a sub-6-hour “Complete Pytest Crash Course” on January 29, 2026, and Test Automation University’s “Introduction to pytest,” hosted by Applitools, was refreshed in April 2026 and now assumes pytest 8.x:

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