pytest

pytest is the dominant third-party testing framework in the Python ecosystem, replacing boilerplate test classes with plain functions, plain assert statements, and powerful fixtures.

pytest is a third-party testing framework (installed via pip, not part of the standard library) that lets you write tests as ordinary functions using the built-in assert statement, then adds test discovery, fixtures, parametrization, and a large plugin ecosystem on top. The standard library's own testing tool is unittest, which pytest can run alongside and even discover, but pytest's syntax and tooling are what most real-world Python projects use day to day.

What Is pytest and Why It's Different

pytest is a third-party framework: it must be installed with pip install pytest and is not part of the standard library. The standard library ships unittest, which requires subclassing TestCase and using methods like assertEqual. pytest instead lets you write a test as a plain function containing plain Python assert statements, and it rewrites those asserts at import time to produce rich failure messages without any special assertion methods to memorize.

pytest can also discover and run unittest-style TestCase classes, which is why many teams migrate incrementally rather than rewriting every test at once.

# unittest style (standard library)
import unittest

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

# pytest style (third-party)
def test_addition():
    assert 2 + 2 == 4

Installing and Running pytest

Running the bare pytest command from the project root is the common case: pytest walks the directory tree, collects test files, and reports a summary of passed, failed, and skipped tests.

pip install pytest
pytest                 # discover and run every test in the project
pytest test_math.py    # run one file
pytest test_math.py::test_addition   # run one test function
pytest -k "addition"   # run tests whose name matches a substring/expression
pytest -v              # verbose output, one line per test
pytest -x              # stop after the first failure

Test Discovery Rules

pytest finds tests by convention, not by a registry you maintain. By default it collects files matching test_*.py or *_test.py, then within those files it collects functions prefixed test_ and classes prefixed Test (without an __init__ method) whose methods are prefixed test_.

These prefixes are configurable via pytest.ini or pyproject.toml (python_files, python_classes, python_functions), but the defaults are what most projects use unchanged.

project/
  src/
    calculator.py
  tests/
    test_calculator.py   # collected: matches test_*.py
    calculator_test.py   # also collected: matches *_test.py
    helpers.py            # NOT collected: no matching prefix/suffix

# inside test_calculator.py:
def test_add(): ...        # collected: starts with test_
def helper_add(): ...      # NOT collected
class TestCalculator:      # collected: starts with Test
    def test_subtract(self): ...

Assertions and Introspection

Because pytest rewrites assert statements in test modules, a failing assert shows the actual values on both sides of the comparison automatically, without you writing a custom message.

For expected exceptions, use the pytest.raises context manager rather than a try/except block in the test body:

def test_list_contents():
    result = [1, 2, 3]
    assert result == [1, 2, 4]
E       assert [1, 2, 3] == [1, 2, 4]
E         At index 2 diff: 3 != 4
E         Full diff:
E         - [1, 2, 4]
E         + [1, 2, 3]
import pytest

def test_division_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_error_message():
    with pytest.raises(ValueError, match="invalid literal"):
        int("not a number")

Fixtures: Reusable Setup and Teardown

A fixture is a function decorated with @pytest.fixture that supplies a value, resource, or setup/teardown behavior to test functions. A test requests a fixture simply by naming it as a parameter; pytest matches the name and calls the fixture function automatically.

A fixture can also perform teardown by yielding instead of returning: code before yield is setup, code after is teardown, run even if the test fails.

import pytest

@pytest.fixture
def sample_data():
    return {"name": "Ada", "age": 36}

def test_name(sample_data):
    assert sample_data["name"] == "Ada"

def test_age(sample_data):
    assert sample_data["age"] == 36
@pytest.fixture
def tmp_log_file(tmp_path):
    path = tmp_path / "app.log"
    path.write_text("")
    yield path              # test runs here
    # teardown runs after the test, pass or fail
    if path.exists():
        path.unlink()

Fixture Scope

By default a fixture is recreated for every test function (scope='function'). Setting a wider scope reuses the same fixture instance across many tests, which is useful for expensive setup like a database connection.

Wider scopes speed up test suites but risk shared mutable state leaking between tests; prefer function scope unless the setup cost genuinely justifies sharing.

@pytest.fixture(scope="module")
def db_connection():
    conn = connect_to_test_db()
    yield conn
    conn.close()

# scope options: "function" (default), "class", "module", "package", "session"

Parametrization

The @pytest.mark.parametrize decorator runs the same test function once per set of input values, avoiding copy-pasted near-duplicate tests.

pytest reports each parameter set as a separate test case in the output (e.g. test_square[2-4]), so a single failing input is pinpointed exactly.

import pytest

@pytest.mark.parametrize("value, expected", [
    (2, 4),
    (3, 9),
    (-2, 4),
    (0, 0),
])
def test_square(value, expected):
    assert value ** 2 == expected

Markers

Markers tag tests with metadata that pytest or plugins act on. Built-in markers include skip, skipif, and xfail; custom markers can be registered for selecting subsets of tests with -m.

import sys
import pytest

@pytest.mark.skip(reason="not implemented yet")
def test_future_feature():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="posix only")
def test_file_permissions():
    ...

@pytest.mark.xfail(reason="known bug, tracked in issue #42")
def test_known_bug():
    assert compute_total() == 100

@pytest.mark.slow
def test_full_pipeline():
    ...
pytest -m slow          # run only tests marked "slow"
pytest -m "not slow"    # skip tests marked "slow"

conftest.py and Sharing Fixtures

A file named conftest.py holds fixtures (and hooks) shared across every test file in its directory and subdirectories, without needing an import. pytest loads it automatically based on location.

tests/
  conftest.py        # fixtures here are visible to every test below
  test_api.py
  integration/
    conftest.py       # additional fixtures, visible only under integration/
    test_end_to_end.py

The Plugin Ecosystem

pytest's behavior is extended almost entirely through third-party plugins, also installed via pip. Common ones include pytest-cov (coverage reporting), pytest-mock (a thin fixture wrapper around unittest.mock), pytest-xdist (parallel test execution), and pytest-django/pytest-asyncio for framework integration.

Because plugins are third-party packages, always distinguish 'pytest core' behavior from 'this is a pytest-cov feature' when reading documentation or blog posts.

pip install pytest-cov
pytest --cov=src --cov-report=term-missing

Practical Usage and Best Practices

Keep tests independent (no test should depend on another test's side effects), keep fixtures small and composable, prefer parametrize over near-duplicate test functions, and name tests descriptively (test_withdraw_raises_when_balance_insufficient reads better than test_case_3).

Use tmp_path (a built-in fixture giving a fresh temporary directory as a pathlib.Path) instead of writing to real project files, and use monkeypatch (a built-in fixture) instead of manually mutating and restoring global state.

def test_writes_output_file(tmp_path):
    out = tmp_path / "result.txt"
    write_report(out, data={"x": 1})
    assert out.read_text() == "x=1\n"

def test_uses_env_var(monkeypatch):
    monkeypatch.setenv("API_MODE", "test")
    assert current_mode() == "test"

Complete Example

divide() is the code under test, kept free of any testing concerns test_divide_valid_inputs uses parametrize to cover several numeric cases in one function test_divide_by_zero_raises uses pytest.raises to assert the exception type and message

Running pytest -v reports four separate results: three parametrized passes plus the exception test

# src/calculator.py
def divide(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("cannot divide by zero")
    return a / b


# tests/test_calculator.py
import pytest
from src.calculator import divide

@pytest.fixture
def sample_pairs():
    return [(10, 2, 5.0), (9, 3, 3.0), (-6, 3, -2.0)]

@pytest.mark.parametrize("a, b, expected", [
    (10, 2, 5.0),
    (9, 3, 3.0),
    (-6, 3, -2.0),
])
def test_divide_valid_inputs(a, b, expected):
    assert divide(a, b) == expected

def test_divide_by_zero_raises():
    with pytest.raises(ValueError, match="cannot divide by zero"):
        divide(5, 0)

Mental Model and Quick Reference

Think of pytest as three layers stacked on plain Python: discovery (find functions named test_*), assertion rewriting (make plain assert informative), and fixtures (dependency injection by parameter name). Everything else — markers, parametrize, plugins — builds on that base.

@pytest.fixture              -> reusable setup/teardown, requested by name
@pytest.mark.parametrize     -> run one test with many inputs
@pytest.mark.skip / xfail    -> mark tests as skipped / expected-to-fail
pytest.raises(Exc)           -> assert an exception is raised
conftest.py                  -> fixtures shared across a directory
tmp_path, monkeypatch, capsys -> built-in fixtures for common needs

Key Takeaways

pytest is a third-party framework (pip install pytest), distinct from the standard library's unittest.

Tests are plain functions using plain assert statements; pytest rewrites asserts for readable failure output.

Discovery follows naming conventions: test_*.py files, test_ functions, Test classes.

Fixtures provide setup/teardown via dependency injection by parameter name; scope controls how often they're recreated.

parametrize turns one test function into many test cases without duplicating code.

Markers (skip, skipif, xfail, custom) tag and select subsets of tests.

Most advanced behavior (coverage, parallelism, framework integration) comes from third-party plugins, not pytest core.

def test_stack(): s = Stack() s.push(1) assert s.pop() == 1 assert s.pop() == 1 # relies on a previous test's leftover state def test_stack_pop_returns_last_pushed(): s = Stack() s.push(1) assert s.pop() == 1 def test_stack_pop_empty_raises(): s = Stack() with pytest.raises(IndexError): s.pop()

Tests should not depend on shared mutable state or execution order; each test should set up exactly what it needs.

def test_parse(): try: parse("bad input") assert False, "should have raised" except ValueError: pass def test_parse_raises_on_bad_input(): with pytest.raises(ValueError): parse("bad input") pytest.raises is the idiomatic way to assert an exception; manual try/except is verbose and easy to get subtly wrong (e.g. catching too broad an exception).

@pytest.fixture(scope="session") def counter(): return {"n": 0} # shared mutable dict reused across the whole test session

@pytest.fixture def counter(): return {"n": 0} # function scope: a fresh dict for every test

A wide-scoped fixture that returns a mutable object is silently shared and mutated across tests; default to function scope unless sharing is intentional and the object is treated as read-only.

def test_naming_1(): assert add(2, 3) == 5 def test_naming_2(): assert add(-1, 1) == 0 def test_add_positive_numbers(): assert add(2, 3) == 5 def test_add_cancels_to_zero(): assert add(-1, 1) == 0

Numbered or vague test names give no information when a test fails; descriptive names document intent and make failures self-explanatory in CI output.

What must be true of a file and a function name for pytest to discover and run it by default?

The file must match test_*.py or *_test.py, and the function must be named test_* (or be a test_* method on a Test-prefixed class).

How does a test function 'request' a fixture, and how does pytest know which fixture function to call?

By naming the fixture as a parameter of the test function; pytest matches the parameter name to a fixture of the same name (looked up in the test file and any conftest.py in scope).

Why does pytest.raises produce a better test than wrapping the call in try/except and asserting False on no exception?

pytest.raises is concise, checks the exact exception type (and optionally message) declaratively, and cannot accidentally pass when the wrong exception type is raised, unlike a loose except clause.

What is the effect of changing a fixture's scope from 'function' to 'session'?

The fixture is created once per test session and reused by every test that requests it, instead of being recreated fresh for each test — faster for expensive setup but risks state leaking between tests.

Why is pytest described as a third-party framework rather than part of Python's standard library?

It is not shipped with CPython; it must be installed separately via pip, unlike unittest which is a standard library module.

How does @pytest.mark.parametrize reduce test duplication?

It runs one test function body once per supplied set of arguments, so covering many input/expected-output pairs doesn't require writing a near-identical function for each one.

What is the practical difference between @pytest.mark.skip and @pytest.mark.xfail?

skip means the test is not run at all (with a stated reason); xfail means the test does run but a failure is expected and does not fail the overall suite (an unexpected pass is flagged as XPASS).

Write a pytest fixture named 'sample_user' that returns a dict with keys 'name' and 'email', and a test that uses it to assert the email contains an '@'.

import pytest @pytest.fixture def sample_user(): return {"name": "Grace Hopper", "email": "grace@example.com"} def test_user_email_has_at_sign(sample_user): assert "@" in sample_user["email"]

The fixture is requested by naming it as the test's parameter; pytest calls sample_user() and injects the returned dict before the test body runs.

How many separate test results does pytest -v report for this file, and when does the print('collected') line run relative to the tests?

Three test results are reported (test_positive[1], test_positive[2], test_positive[3]), one per parametrized value. The print statement executes once at module import/collection time, before any test runs, and appears in pytest's captured output rather than inline with the -v results unless capture is disabled.

Write a test using pytest.raises to assert that calling int('abc') raises a ValueError.

import pytest def test_int_conversion_raises_value_error(): with pytest.raises(ValueError): int("abc") pytest.raises(ValueError) is used as a context manager; if int('abc') does not raise a ValueError inside the block, the test itself fails.

In what order do 'setup', 'test body', and 'teardown' print when this test runs?

In order: 'setup' (code before yield runs first), then 'test body' (the test executes with the yielded value), then 'teardown' (code after yield runs after the test completes).

import pytest

@pytest.mark.parametrize("n", [1, 2, 3])
def test_positive(n):
    assert n > 0

print("collected")
import pytest

@pytest.fixture
def data():
    print("setup")
    yield [1, 2, 3]
    print("teardown")

def test_len(data):
    assert len(data) == 3
    print("test body")