mrkeyoor.com_
Wed 05 Aug 05:01 UTC
PyPITestingupdated 05 Aug 2026

pytest

pytest is the de facto standard test framework for Python. You write plain functions with plain assert statements, and pytest rewrites those asserts so failures show the actual values on each side instead of a bare AssertionError. Around that core it adds auto-discovery of test files, fixtures (dependency-injected setup/teardown resources declared once and requested by argument name), parametrization for running one test across many inputs, and a hook-based plugin architecture with over 1300 external plugins. It also runs existing unittest suites out of the box, so adoption in a legacy codebase does not require a rewrite. Current major is 9, requiring Python 3.10+ or PyPy3.

Verdict

The right default for testing Python, full stop; 265M weekly downloads and the plugin ecosystem back that up. The only real debate is stdlib purists choosing unittest, and even they usually end up running it under pytest.

API stability4/5Core usage (assert, fixtures, parametrize) has been stable for many years and deprecations are warned about across releases before removal; majors like 8 and 9 do remove long-deprecated hooks, which periodically breaks older plugins.
Docs5/5docs.pytest.org has getting-started, how-to guides, explanation pages, and a full reference including a generated list of 1300+ plugins; failure output itself is documented and the changelog is thorough.
Maintenance5/5Active since 2004, pushed 2026-08-03, with a maintainer team, Open Collective and Tidelift funding, a security advisory process, and a steady release cadence on the 9.x line.
Ecosystem5/5265M weekly downloads, 1300+ external plugins per the README, and first-class integration points in virtually every Python framework and CI system.

Use it if

  • You want readable tests: plain assert with detailed failure introspection instead of memorizing self.assertEqual and friends
  • Your tests share expensive resources (databases, temp dirs, servers); fixtures with scopes and yield-based teardown handle this far better than setUp/tearDown
  • You need to run one test across many inputs; @pytest.mark.parametrize turns a table of cases into individually reported tests
  • You have an existing unittest suite; pytest runs it unchanged, so you can adopt incrementally
  • You want ecosystem depth: coverage, parallel execution (pytest-xdist), async support, and framework integrations all exist as maintained plugins
Skip it if

Setup reality

pip install pytest is quick and pulls only small pure-Python dependencies (iniconfig, packaging, pluggy, pygments, plus colorama on Windows). The annoyances are configuration and discovery, not installation: config can live in pytest.ini, pyproject.toml, tox.ini, or setup.cfg and figuring out which one wins takes a docs trip; rootdir and import-mode discovery rules surprise people with duplicate-basename test files or missing __init__.py packages; and assertion rewriting occasionally confuses tools that import test modules directly. Plan a plugin compatibility check before every major upgrade, since removed hooks break older plugins.

Patterns

Write and run a first testbasic-test

# content of test_sample.py
def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 4

# run:  pytest            (discovers test_*.py files)
# or:   pytest test_sample.py -v

Files must match test_*.py or *_test.py and functions must start with test_ or discovery skips them silently.

Share setup with a fixturefixture

import pytest

@pytest.fixture
def db():
    conn = create_connection()
    yield conn
    conn.close()  # teardown runs even if the test fails

def test_query(db):
    assert db.execute('select 1') == [(1,)]

Code after yield is the teardown; requesting the fixture is just naming an argument, there is no import of it.

Run one test over many inputsparametrize

import pytest

@pytest.mark.parametrize('raw,expected', [
    ('42', 42),
    ('-7', -7),
    ('  8 ', 8),
])
def test_parse_int(raw, expected):
    assert int(raw) == expected

Each tuple becomes a separately reported test; stack parametrize decorators to get the full cross product.

Assert that code raises an exceptionassert-raises

import pytest

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

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

match is a regex search against the exception string, not an exact-equality check.

Use a temporary directorytmp-path

def test_write_config(tmp_path):
    cfg = tmp_path / 'config.toml'
    cfg.write_text('[app]\nname = "demo"\n')
    assert cfg.read_text().startswith('[app]')

tmp_path is a built-in fixture yielding a fresh pathlib.Path per test; prefer it over the older tmpdir (py.path) fixture.

Patch environment and attributes safelymonkeypatch

def test_reads_env(monkeypatch):
    monkeypatch.setenv('API_KEY', 'test-key')
    monkeypatch.setattr('myapp.client.timeout', 1)
    assert myapp.client.build_headers()['Authorization'] == 'Bearer test-key'

All monkeypatch changes are undone automatically after the test; no try/finally cleanup needed.

Skip tests or mark expected failuresskip-xfail

import sys
import pytest

@pytest.mark.skipif(sys.platform == 'win32', reason='POSIX only')
def test_unix_sockets():
    ...

@pytest.mark.xfail(reason='known bug #123')
def test_unfixed_bug():
    assert broken() == 'fixed'

An xfail test that unexpectedly passes reports as XPASS; add strict=True to turn that into a failure so fixes get noticed.

Share fixtures across files with conftest.pyconftest-shared-fixtures

# tests/conftest.py
import pytest

@pytest.fixture(scope='session')
def app_config():
    return {'env': 'test', 'debug': True}

# tests/test_api.py (no import needed)
def test_uses_config(app_config):
    assert app_config['env'] == 'test'

conftest.py fixtures are visible to all tests in that directory and below; scope='session' builds the fixture once per run.

Run a subset of the suiteselect-tests

pytest tests/test_api.py::test_login      # one test by node id
pytest -k 'login and not slow'            # match test names by expression
pytest -m integration                     # only tests marked @pytest.mark.integration
pytest --lf                               # only tests that failed last run

Custom marks must be registered in config (markers = ...) or pytest warns and, under strict markers, errors.

Compare floats without brittle equalityfloat-compare

import pytest

def test_math():
    assert 0.1 + 0.2 == pytest.approx(0.3)
    assert [0.1 + 0.2, 1.0] == pytest.approx([0.3, 1.0])

approx works on scalars, sequences, and dicts, with rel/abs tolerance arguments when the defaults are too tight.

Assert on printed outputcapture-output

def greet():
    print('hello world')

def test_greet(capsys):
    greet()
    captured = capsys.readouterr()
    assert captured.out == 'hello world\n'

readouterr() drains the buffer; call it once and keep the result, a second call returns empty output.

Run an existing unittest suite under pytestrun-unittest-suite

# no changes needed to the legacy suite
pytest tests/legacy/

# unittest classes gain pytest features gradually:
import unittest

class TestLegacy(unittest.TestCase):
    def test_old_style(self):
        self.assertEqual(1 + 1, 2)

unittest tests run as-is, but pytest fixtures cannot be injected into TestCase methods; migrate classes to plain functions to use them.

Alternatives

PackageRegistryPick it when
hypothesisPyPIYou want property-based testing that generates inputs and shrinks failures, alongside or inside pytest
nose2PyPIYou are maintaining a legacy nose-style suite and need a minimal unittest-based runner
robotframeworkPyPINon-developers write keyword-driven acceptance tests rather than Python code