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.
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.
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
- Your organization mandates stdlib-only tooling; unittest ships with Python and pytest's assertion rewriting and fixture injection are extra machinery and magic to audit
- You dislike implicit behavior: fixtures are injected by matching parameter names against conftest.py hierarchies, and in large codebases tracing where a fixture comes from is genuinely annoying
- You are pinned to an old interpreter; pytest 9 requires Python 3.10+, and older interpreter versions cap you at older pytest lines
- Your suite leans on many third-party plugins; pytest majors deprecate and remove hooks on a cycle, and every major release strands some unmaintained plugins until you replace them
- Non-programmers write your acceptance tests; keyword-driven tools like robotframework fit that workflow better than Python test functions
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 -vFiles 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) == expectedEach 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 runCustom 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
| Package | Registry | Pick it when |
|---|---|---|
| hypothesis | PyPI | You want property-based testing that generates inputs and shrinks failures, alongside or inside pytest |
| nose2 | PyPI | You are maintaining a legacy nose-style suite and need a minimal unittest-based runner |
| robotframework | PyPI | Non-developers write keyword-driven acceptance tests rather than Python code |