pytest: the Python testing framework that makes you want to write tests

From your first assert to fixtures, parametrize, mocking, plugins and the changes that arrived in pytest 9.0. A practical guide that sticks to what you will use.

Most of us have skipped writing tests at some point because the setup felt like more work than the feature. You write a function, it works in the REPL, you ship it and move on. Two weeks later something breaks somewhere unrelated, and you spend two hours finding out why.

That is the real cost of untested code. The bugs you find straight away are cheap. The ones that surface later, in places you did not expect, are the ones that eat your time.

pytest removes most of the friction between "I should write tests" and writing them. There are no base classes to inherit, no self.assertEqual, no ceremony. You write plain Python functions with plain assert statements, and pytest gives you readable failures, reusable setup and a test suite that stays manageable as it grows.

This guide is written against pytest 9.0.2, released in December 2025. It requires Python 3.10 or later.

Why pytest over unittest

Python ships with unittest in the standard library, so why install anything else? Here is the same pair of tests written both ways.

# unittest style
import unittest


class TestMath(unittest.TestCase):
    def test_addition(self):
        self.assertEqual(1 + 1, 2)

    def test_type_error(self):
        with self.assertRaises(TypeError):
            "string" + 1
# pytest style
import pytest


def test_addition():
    assert 1 + 1 == 2


def test_type_error():
    with pytest.raises(TypeError):
        "string" + 1

The pytest version is just Python: no class inheritance and no assertion methods to memorise. When something fails, the report tells you far more than unittest does, as the next sections show.

pytest also runs existing unittest-style tests without changes, so switching does not mean rewriting your suite.

Installation and first run

pip install pytest
# or, with uv, as a development dependency
uv add --dev pytest

Check the version:

pytest --version
# pytest 9.0.2

Create your first test file:

# test_calculator.py
def add(a, b):
    return a + b


def test_add():
    assert add(2, 3) == 5


def test_add_negative():
    assert add(-1, 1) == 0


def test_add_floats():
    assert add(1.5, 2.5) == 4.0

Run it:

pytest test_calculator.py
# or, to run everything pytest can find
pytest

pytest discovers files named test_*.py or *_test.py, and inside them any function whose name starts with test. You need no configuration to get started.

Reading the output

Failure messages are one of pytest's best features. When a test fails, pytest does not just say that it failed; it shows you the values involved.

def test_add_wrong():
    assert add(2, 2) == 5

Running this with pytest 9.0.2 prints:

    def test_add_wrong():
>       assert add(2, 2) == 5
E       assert 4 == 5
E        +  where 4 = add(2, 2)

Compare that with unittest's AssertionError: 4 != 5. pytest rewrites the assert statement so it can show each part of the expression, including the call that produced the 4. For dictionaries, lists and other containers it goes further and reports which items differ.

Fixtures: the heart of pytest

If you learn one pytest concept in depth, make it fixtures. They are what keep tests clean, reusable and maintainable.

A fixture is a function decorated with @pytest.fixture. Any test that names it as a parameter receives its return value.

import pytest


@pytest.fixture
def sample_user():
    return {"name": "Alice", "age": 30, "email": "alice@example.com"}


def test_user_name(sample_user):
    assert sample_user["name"] == "Alice"


def test_user_email(sample_user):
    assert sample_user["email"] == "alice@example.com"

pytest sees that test_user_name has a parameter called sample_user, finds the fixture with that name, runs it and passes the result in. Each test gets its own fresh dictionary.

Setup and teardown with yield

To open a resource before a test and close it afterwards, use yield:

@pytest.fixture
def db_connection():
    conn = create_database_connection()  # setup
    yield conn                           # the test runs here
    conn.close()                         # teardown

The code after yield runs once the test finishes, whether it passed or failed, as long as the setup before yield completed. This replaces setUp and tearDown from unittest, and it reads better because setup and teardown sit together in one function.

Fixture scope: how long a fixture lives

By default, a fixture is created fresh for every test. The scope argument changes that:

@pytest.fixture(scope="module")  # once per test file
def expensive_setup():
    return initialize_heavy_resource()


@pytest.fixture(scope="session")  # once for the whole test run
def global_config():
    return load_config()
ScopeCreatedDestroyed
function (default)Before each testAfter each test
classOnce per test classAfter the last test in the class
moduleOnce per test fileAfter the last test in the file
packageOnce per packageAfter the last test in the package
sessionOnce per pytest runAt the end of the run

Use session scope for things that are expensive to create, such as database connections or API clients. Use function scope for anything that must be isolated between tests.

Fixtures that use other fixtures

A fixture can depend on other fixtures. Add them as parameters:

@pytest.fixture
def db():
    return setup_database()


@pytest.fixture
def logged_in_user(db):  # pytest injects the db fixture
    user = db.create_user("alice")
    db.login(user)
    return user


def test_user_dashboard(logged_in_user):
    assert logged_in_user.is_logged_in

pytest works out the dependency graph for you. Being able to compose fixtures like this is what keeps setup manageable in a large suite.

conftest.py: sharing fixtures across files

When several test files need the same fixtures, put them in a conftest.py file. pytest finds it on its own, so the test files do not import anything.

my_project/
+-- src/
|   +-- ...
+-- tests/
    +-- conftest.py          # shared fixtures live here
    +-- test_calculator.py
    +-- test_user.py
    +-- test_products.py
# tests/conftest.py
import pytest


@pytest.fixture(scope="session")
def api_client():
    client = create_test_client()
    yield client
    client.teardown()

Every test in every file under that directory can now take api_client as a parameter.

Parametrize: many inputs, one test

This is one of pytest's most used features. Instead of copying the same test for each set of values:

# the repetitive way
def test_add_positive():
    assert add(1, 2) == 3


def test_add_zero():
    assert add(0, 5) == 5


def test_add_negative():
    assert add(-1, 1) == 0

use @pytest.mark.parametrize:

# the pytest way
@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 5, 5),
    (-1, 1, 0),
    (100, -50, 50),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

This runs four separate tests, each with its own result. If the third case fails, the other three still pass and are reported as such.

You can also parametrize over more complex objects, give individual cases an ID or a mark with pytest.param, and combine parametrize with fixtures.

Subtests, new in pytest 9.0

pytest 9.0, released in November 2025, brought the pytest-subtests plugin into core as the built-in subtests fixture. Subtests are an alternative to parametrize for cases you only know at run time.

def test_all_users_have_emails(subtests):
    users = fetch_users_from_db()  # any number of users

    for user in users:
        with subtests.test(
            msg=f"User {user.name} missing email",
            name=user.name,
        ):
            assert user.email is not None

With @pytest.mark.parametrize, every case must be known when pytest collects the tests. Subtests let you create the cases while the test runs, which is what you need when you iterate over database records, API responses or any other run-time data.

If one subtest fails, the others still run and each failure is reported on its own line. You get one logical test with a separate result for each case. The pytest changelog marks the feature as experimental in 9.0, so expect its details to change in later releases.

Marking tests: skip, xfail and custom marks

Skip a test

import sys

import pytest


@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
    assert do_something_not_ready()


@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_specific():
    assert check_linux_thing()

Expected failures with xfail

@pytest.mark.xfail(reason="Known bug, tracked in issue #42")
def test_known_broken():
    assert broken_function() == "expected"

If the test fails, pytest reports it as xfail, an expected failure that raises no alarm. If it passes, pytest reports xpass, an unexpected pass that tells you the mark is out of date. With strict_xfail = true in your configuration, an unexpected pass fails the run, which is the right behaviour for most projects. strict_xfail was added in 9.0 as an alias for the older xfail_strict option, and both names work.

Custom marks

# Declare the marks in pyproject.toml:
# [tool.pytest]
# markers = [
#     "slow: marks tests as slow",
#     "integration: requires external services",
# ]


@pytest.mark.slow
def test_something_heavy():
    ...


@pytest.mark.integration
def test_against_real_api():
    ...

Then select tests by mark:

pytest -m slow          # only slow tests
pytest -m "not slow"    # skip slow tests, handy for quick local runs
pytest -m integration   # only integration tests

This pattern pays off in CI: run the fast unit tests on every push and the integration tests only on merges to main.

Testing exceptions

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b


def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)


def test_exception_details():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "zero" in str(exc_info.value)

The match argument is a regular expression that is searched for in the exception message. It is a small detail that stops a test from passing because the right exception type was raised for the wrong reason.

Mocking with monkeypatch and unittest.mock

monkeypatch, built into pytest

def test_env_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    assert get_api_key() == "test-key-123"


class MockResponse:
    status_code = 200

    def json(self):
        return {"ok": True}


def test_patch_function(monkeypatch):
    monkeypatch.setattr(
        "mymodule.requests.get", lambda url: MockResponse()
    )
    result = fetch_data("https://api.example.com")
    assert result is not None

monkeypatch undoes every change when the test finishes, so there is nothing to clean up.

unittest.mock, for more complex cases

from unittest.mock import patch


def test_email_sent():
    with patch("myapp.email.send_email") as mock_send:
        mock_send.return_value = True
        result = register_user("alice@example.com")
        mock_send.assert_called_once_with(
            "alice@example.com", subject="Welcome!"
        )
        assert result is True

Both work well with pytest. monkeypatch is lighter and reads cleanly for simple replacements; unittest.mock gives you call recording and assertions for more involved interactions.

Native TOML configuration, new in pytest 9.0

Before pytest 9.0, configuring pytest in pyproject.toml meant the [tool.pytest.ini_options] table, an INI compatibility mode in which every value was read as a string. pytest 9.0 adds a native [tool.pytest] table that uses real TOML types:

# pyproject.toml, native TOML configuration (pytest 9.0+)
[tool.pytest]
minversion = "9.0"
testpaths = ["tests", "integration"]
addopts = ["-ra", "-q"]
log_level = "INFO"

# One switch for all strictness options, new in 9.0
strict = true

markers = [
    "slow: marks tests as slow (deselect with -m 'not slow')",
    "integration: marks tests requiring external services",
]

Lists are TOML arrays now, not space-separated strings. markers is still a list of "name: description" strings; a [tool.pytest.markers] table is rejected with a type error.

The old [tool.pytest.ini_options] table still works, but you cannot use both in the same file; pytest refuses to start if it finds both. Moving across is mostly a matter of turning strings into lists.

If you would rather keep pytest's settings out of pyproject.toml, pytest 9.0 also reads a standalone pytest.toml or .pytest.toml file, with the settings under a [pytest] table.

Strict mode, new in pytest 9.0

pytest 9.0 gathers the individual strictness options under one strict option:

[tool.pytest]
strict = true  # turns on all of these:
               # strict_config: unknown config options are errors
               # strict_markers: undeclared markers are errors
               # strict_parametrization_ids: duplicate IDs are errors
               # strict_xfail: an unexpected pass fails the run

You can still set any of the four options on its own to override what strict chooses. For a mature project this is good practice. Undeclared markers, duplicate parametrize IDs and misspelled options used to cause quiet, confusing behaviour; now they stop the run with a clear error. Turn it on, fix what it reports, and your suite will be cleaner for it.

strict_config also rejects options that belong to plugins you have not installed. For example, timeout = 30 comes from the pytest-timeout plugin; without the plugin, pytest stops with Unknown config option: timeout.

Temporary files and directories

pytest provides built-in fixtures for working with the file system safely:

def test_write_and_read(tmp_path):
    # tmp_path is a pathlib.Path to a unique temporary directory
    file = tmp_path / "output.txt"
    file.write_text("hello pytest")
    assert file.read_text() == "hello pytest"


def test_multiple_files(tmp_path):
    (tmp_path / "data").mkdir()
    config = tmp_path / "config.json"
    config.write_text('{"debug": true}')
    assert config.exists()

Each test gets its own directory, so tests cannot trip over each other's files. pytest does not delete the directory as soon as the test ends: by default it keeps the temporary directories of the last three runs, which is useful when you want to inspect what a failing test wrote. The tmp_path_retention_count and tmp_path_retention_policy options change that. tmp_path_factory is the session-scoped counterpart, for fixtures that need to create temporary directories of their own.

Running tests: the commands you will use

# Run all tests
pytest

# Run one file
pytest tests/test_calculator.py

# Run one test function
pytest tests/test_calculator.py::test_add_negative

# Run one parametrized case
pytest "tests/test_calculator.py::test_add[1-2-3]"

# Verbose output, one line per test
pytest -v

# Extra summary for all outcomes except passes
pytest -ra

# Stop after the first failure
pytest -x

# Stop after N failures
pytest --maxfail=3

# Run only tests whose names match an expression
pytest -k "user and not admin"

# Run marked tests
pytest -m slow

# Show print() output instead of capturing it
pytest -s

# Re-run only the tests that failed last time
pytest --lf

# Run last time's failures first, then the rest
pytest --ff

--lf (last failed) is one of the most useful flags in day-to-day work. After fixing a bug, you want to re-run the tests that were failing straight away, not wait for the whole suite.

Coverage: are you testing your code

pip install pytest-cov

pytest --cov=myapp --cov-report=term-missing

# HTML report, easier to browse on larger projects
pytest --cov=myapp --cov-report=html
open htmlcov/index.html  # macOS; use xdg-open on Linux

The HTML report lets you click into each file and see exactly which lines were not run. That tells you far more than a single percentage.

# pyproject.toml: set a minimum coverage threshold
[tool.coverage.report]
fail_under = 80

With this in place, pytest --cov=myapp fails when total coverage drops below 80 percent, which makes a simple gate for CI.

Running tests in parallel

pip install pytest-xdist

pytest -n auto  # one worker per CPU core
pytest -n 4     # exactly 4 workers

On a large suite this can cut run time considerably; how much depends on the number of cores and how CPU-bound your tests are. Tests must be independent of each other, with no shared state, for this to work. That is good practice anyway.

Testing async code

pip install pytest-asyncio
import pytest


@pytest.mark.asyncio
async def test_async_function():
    result = await fetch_data_async()
    assert result is not None

Or turn on auto mode in pyproject.toml:

[tool.pytest]
asyncio_mode = "auto"

With asyncio_mode = "auto", pytest-asyncio treats every async def test as an asyncio test, so you do not need the decorator.

The plugins worth knowing

pytest has a large plugin ecosystem. These are the ones I reach for most:

PluginWhat it does
pytest-covCode coverage reports
pytest-xdistRuns tests in parallel
pytest-asyncioTests async/await code
pytest-mockA mocker fixture that wraps unittest.mock and undoes patches after each test
pytest-djangoDjango integration: database access, test client and more
pytest-httpxMocks requests made with httpx
pytest-benchmarkBenchmarks code inside tests
pytest-randomlyShuffles test order to expose hidden dependencies between tests
pytest-timeoutFails tests that run too long
pytest-snapshotSnapshot testing for complex output

A real-world test layout

Here is what a well-organised pytest project looks like:

my_project/
+-- src/
|   +-- myapp/
|       +-- models.py
|       +-- services.py
|       +-- utils.py
+-- tests/
|   +-- conftest.py          # session and module fixtures
|   +-- unit/
|   |   +-- conftest.py      # unit test fixtures
|   |   +-- test_models.py
|   |   +-- test_utils.py
|   +-- integration/
|   |   +-- conftest.py      # integration fixtures (real DB, etc.)
|   |   +-- test_api.py
|   +-- e2e/
|       +-- test_flows.py
+-- pyproject.toml
# pyproject.toml
[tool.pytest]
testpaths = ["tests"]
addopts = ["-ra", "-q"]
strict = true

markers = [
    "unit: fast isolated unit tests",
    "integration: tests requiring database/external services",
    "e2e: full end-to-end tests",
]
# Different runs for different purposes
pytest -m unit                   # quick local feedback
pytest -m "unit or integration"  # before pushing
pytest                           # full suite in CI

pytest and the alternatives

unittest. It is in the standard library and it is solid, and pytest can run its tests. But the class-based structure, the assertion methods and the thinner failure messages make tests harder to write and maintain. On developer experience, pytest wins clearly.

nose and nose2. nose was the popular alternative to pytest for years, but it is unmaintained: its last release, 1.3.7, dates from 2015. nose2 is still maintained but never gained the same community momentum. pytest is the default choice.

Hypothesis. Hypothesis is not a competitor. It is a property-based testing library that works with pytest. Instead of listing exact inputs, you describe properties your function should have, and Hypothesis generates inputs that try to break them. It pairs well with pytest and is worth exploring once you are comfortable with the basics.

Robot Framework. Robot Framework is built for acceptance testing and uses a keyword-driven syntax rather than Python code. It has its place in QA automation. For unit and integration tests in a Python codebase, pytest is the more natural fit.

Where to go from here

Wrapping up

The idea behind pytest is simple: tests should be easy to write and easy to read, and when they fail, the failure should be easy to understand. Fixtures, parametrize, plugins and the rest of the ecosystem all serve that idea.

The barrier to entry is low. You write Python functions, you use assert, and pytest does the rest. The ceiling is high too: the same framework scales to thousands of tests with complex setup, parallel runs and enforced coverage.

pytest 9.0 added subtests, native TOML configuration and a single strict mode. None of them change how you write a basic test, but each removes a small annoyance from a tool that was already good.

If you have been putting off a proper test suite, this is a good week to start. You will thank yourself the next time something breaks late at night six months from now. If you have questions, I am happy to help.

Sources