Browse the Knowledge Hub211 resources
pytest review: tutorial, what it is, pros and cons
pytest is the standard testing framework for Python. Tests are plain functions using the assert statement, which pytest rewrites to show the values involved when a check fails. Fixtures, injected by argument name, handle setup and teardown, and a large plugin ecosystem adds parallel runs, coverage and browser automation. Version 9 needs Python 3.10 or later; it is free under the MIT licence.
QAble has evaluated this tool and works alongside it. The factual sections are complete; our view is bounded to what we have actually seen rather than a long-term verdict.
- last reviewed
- 13 October 2026
- version covered
- pytest 9.1
- licence
- Open source
At a glance
pytest at a glance
- Vendor
- pytest-dev
- Category
- Unit testing
- Licence
- Open source (MIT)
- Platforms
- Python
- Languages
- Python
- Integrates with
- Selenium, Playwright, Allure, tox, Jenkins
- First released
- 2004
- Version reviewed
- pytest 9.1
Verdict
Our verdict on pytest
Strengths
- Plain assert statements show the compared values on failure, with no assertion API to learn
- Fixtures are injected by argument name and scoped per test, module or session, with teardown after yield
- Parametrize turns one test into many cases, each reported and re-runnable on its own
- Runs existing unittest suites unchanged, so adoption can be gradual
- Plugins cover parallel runs, coverage, retries, Playwright, BDD and much more
- Free under MIT, with optional commercial support through Tidelift
Weaknesses
- Fixture injection by name is implicit: a test’s dependencies can live in several conftest.py files
- Parallel execution needs the pytest-xdist plugin, and session fixtures then run once per worker
- Import and rootdir behaviour confuses newcomers unless the project uses a src layout or editable install
- Version 9 turned long-standing deprecations into errors, which breaks older suites and tutorials
Our take
pytest is the default for Python testing at every level, from unit tests to API and browser suites driven through Playwright or Selenium. The fixture system is the reason: it makes setup explicit, reusable and scoped, which class-based frameworks handle less cleanly. QAble has evaluated it rather than maintained a long-running suite on it, so treat this as a bounded take. The thing to watch is the same flexibility that makes it pleasant. Deep conftest.py hierarchies, autouse fixtures and broad-scoped fixtures with mutable state make a suite hard to reason about, and a plugin-heavy setup ties upgrades to plugin release cycles. Version 9 also removed several long-deprecated behaviours, so older suites should run once with warnings as errors before upgrading.
We have evaluated this tool rather than maintained a long-running suite on it, so this is a bounded view rather than a long-term verdict. The rest of this page is factual.
How it works
What is pytest and how does it work?
Collection by convention
pytest starts from testpaths, or the current directory, and walks the tree. It collects files named test_*.py or *_test.py, functions whose names start with test, and test methods inside classes starting with Test that have no __init__ method. unittest.TestCase subclasses are collected too. Each item gets a node ID, such as tests/test_cart.py::test_total, which is what you pass on the command line to re-run one test. Because collection imports every test module, an import error in one file is reported as a collection error before any test runs, which is usually the first thing newcomers meet.
Assertion rewriting
Instead of assertEqual and its relatives, pytest uses Python’s own assert. When a test module is imported, pytest rewrites its assert statements so that on failure it can report the intermediate values: both sides of a comparison, the differing keys of two dictionaries, the first mismatched item of two lists. Rewriting applies to test modules and conftest.py files automatically; helper modules elsewhere need pytest.register_assert_rewrite, or their asserts fail without the detail. This is why a plain assert in pytest reads better than in a plain Python script.
Fixtures as dependency injection
A fixture is a function decorated with @pytest.fixture. A test asks for it by naming it as an argument, and pytest builds it, and anything it depends on, before the test runs. Code after a yield in the fixture runs as teardown, even if the test fails. Scope decides how long the result lives: function, the default, class, module, package or session. Fixtures defined in a conftest.py are visible to every test beneath that directory without imports. The result is setup that is explicit at the point of use and shared without inheritance, though a reader has to know where to look for the definition.
Hooks and plugins
Almost everything pytest does is implemented as hooks, functions with known names that pytest calls during configuration, collection, running and reporting. Plugins are packages that implement those hooks, registered through entry points when installed, and a conftest.py can implement them locally. That design is why the ecosystem is so large: pytest-xdist distributes tests across processes, pytest-cov wraps coverage, pytest-rerunfailures adds retries, and browser plugins provide ready-made fixtures. It also means installing a plugin can change behaviour for the whole suite without any code change.
Strengths
What it does well
Readable failures with no assertion API
A failed assert shows the expression, the values on each side and, for collections and strings, a diff. Tests stay short, and newcomers write useful checks on the first day without learning a matcher library. pytest.raises and pytest.approx cover exceptions and floating-point comparison.
Parametrisation with clear IDs
@pytest.mark.parametrize runs one test for each set of arguments, with an ID in the node name, so one failing case is reported and re-run on its own. Fixtures can be parametrised too, which runs every dependent test against each variant, such as two database backends.
Built-in fixtures for common chores
tmp_path gives each test its own temporary directory as a pathlib.Path, monkeypatch changes environment variables, attributes and dictionaries and restores them afterwards, capsys captures output, and caplog captures log records. These remove most of the hand-written setup and cleanup that leaks between tests.
Markers and selection
Markers tag tests, such as slow or integration, and -m selects by marker expression, while -k selects by name. Registered markers combined with strict mode turn a misspelled marker into an error instead of a silently unselected test.
Native TOML configuration
Since 9.0, configuration can live in a pytest.toml file or a [tool.pytest] table in pyproject.toml, using real TOML lists and booleans rather than INI strings. The older [tool.pytest.ini_options] table still works, though not in the same file as the new one.
Subtests for loops over data
Version 9.0 brought subtests into core as an experimental feature. The subtests fixture reports each iteration of a loop separately, which suits cases where the data is only known at run time and parametrize cannot be used.
Pricing
pytest pricing and licensing
pytest is free and open source under the MIT licence, maintained by volunteers in the pytest-dev organisation and funded through donations on Open Collective. There is no paid edition and nothing held back. Organisations that need a commercial agreement can buy support through Tidelift, which the pytest documentation describes as pytest for enterprise. The real costs are CI time and the maintenance of fixtures and plugins as the suite grows.
- Licence: MIT for pytest itself. Plugins carry their own licences: pytest-xdist and pytest-cov are MIT, while pytest-rerunfailures is MPL 2.0.
- No tiers: fixtures, parametrisation, markers, the hook system and JUnit XML output are all in the core package on PyPI.
- Commercial support: available through a Tidelift subscription, priced by Tidelift, not by the pytest project.
- Infrastructure: CI minutes scale with suite duration; pytest-xdist cuts wall time on multi-core runners at the cost of more memory.
- Hidden cost: upgrades. Version 9 dropped Python 3.9 and removed long-deprecated features, and each plugin must also support the new major before you can move.
Checked against the vendor’s own pages on 13 October 2026. Pricing changes; confirm on the official site before buying.
Getting started
pytest tutorial: getting started
Setup, step by step
- Use Python 3.10 or later and create a virtual environment for the project.
- Install pytest, and install your package in editable mode with pip install -e .
- Create a tests directory and a file named test_something.py inside it.
- Write functions whose names start with test_ and check results with plain assert.
- Run pytest from the project root and read the summary line for the test count.
- Add a pytest.toml or a [tool.pytest] table so testpaths and options are shared by everyone.
Install
python -m venv .venv
# activate it, then:
pip install -U pytest
pip install -e .
pytestA first example
# tests/test_cart.py
import pytest
from shop.cart import Cart
def test_total_sums_line_items():
cart = Cart()
cart.add("SKU-1", quantity=2, unit_pence=1500)
cart.add("SKU-2", quantity=1, unit_pence=499)
# On failure pytest prints both sides of the comparison
assert cart.total_pence() == 3499
def test_rejects_zero_quantity():
cart = Cart()
# match is a regular expression checked against the message
with pytest.raises(ValueError, match="quantity must be positive"):
cart.add("SKU-1", quantity=0, unit_pence=1500)What trips people up first
ModuleNotFoundError at collection time. The test imports shop.cart, but pytest cannot find the package because it is not installed and not on sys.path. The fix the pytest documentation recommends is a src layout with the package installed in editable mode, pip install -e ., so tests import exactly what users would. For a quick start without packaging, set pythonpath = ["src"] in the pytest configuration. Avoid sprinkling __init__.py files or sys.path changes through the tests to make imports work, because that ties the suite to the directory it happens to be run from.
In practice
Using pytest in practice
Project layout and shared configuration
Keep application code in src/yourpackage and tests in a top-level tests directory, mirroring the package structure where it helps. Put configuration in pytest.toml or a [tool.pytest] table so every developer and CI run use the same options. Set testpaths so a bare pytest only collects tests, use the importlib import mode the documentation recommends for new projects, register every marker, and turn on strict mode once the pytest version is pinned. Strict mode enables strict configuration, markers, parametrisation IDs and xfail together, so typos and unexpected passes surface as errors.
# pytest.toml (pytest 9.0 or later)
[pytest]
minversion = "9.0"
testpaths = ["tests"]
addopts = ["-ra", "--import-mode=importlib"]
# Fail on unknown markers, bad config keys and unexpected xfail passes
strict = true
markers = [
"slow: takes more than a few seconds",
"integration: needs the database container",
]Fixtures with clear scopes and teardown
Put shared fixtures in a conftest.py at the level where they are needed, not all at the root. Use yield fixtures so teardown sits beside setup and runs even when the test fails. Choose the narrowest scope that is fast enough: an expensive resource such as a database engine can be session-scoped, while anything a test can change, such as a transaction or an API client with state, should be function-scoped. A broad-scoped fixture may only depend on fixtures of the same or broader scope; pytest raises a ScopeMismatch error otherwise. Factory fixtures, which return a function, are the clean way to create several objects per test.
# tests/conftest.py
import pytest
from shop.db import connect
@pytest.fixture(scope="session")
def engine():
# Created once for the whole run, closed at the end
eng = connect("postgresql://localhost/shop_test")
yield eng
eng.close()
@pytest.fixture
def db(engine):
# Function scope: each test gets a transaction that is rolled back
tx = engine.begin()
yield tx
tx.rollback()
@pytest.fixture
def make_customer(db):
# A factory fixture: call it as often as the test needs
def _make(name="Ada", tier="standard"):
return db.customers.insert(name=name, tier=tier)
return _make
# tests/test_discounts.py
def test_gold_customer_gets_ten_percent(make_customer):
customer = make_customer(tier="gold")
assert customer.discount_percent() == 10Data-driven tests with parametrize and markers
Parametrize when the same behaviour should hold for several inputs. Give cases readable IDs with pytest.param and id, so a failure names the case rather than an index, and mark individual cases with pytest.param marks, for example an xfail with a reason pointing to a known bug. Combine with markers for selection: mark slow or integration tests, run pytest -m "not slow" on every commit and the full suite on a schedule. Avoid generating huge cartesian products by stacking parametrize decorators unless every combination matters, because each one becomes a separate test.
import pytest
from shop.pricing import apply_code
@pytest.mark.parametrize(
("pence", "code", "expected"),
[
pytest.param(1000, "NONE", 1000, id="no-code"),
pytest.param(1000, "SAVE10", 900, id="ten-percent"),
pytest.param(999, "SAVE10", 900, id="rounds-up"),
pytest.param(
0, "SAVE10", 0, id="empty-cart",
marks=pytest.mark.xfail(reason="bug 412", strict=True),
),
],
)
def test_apply_code(pence, code, expected):
assert apply_code(pence, code) == expectedRunning pytest in GitHub Actions
Test against each Python version you support with a matrix, cache pip downloads through setup-python, and install the package plus test dependencies. Run with -n auto from pytest-xdist to use every core on the runner, write JUnit XML with --junitxml so CI can display results, and upload the reports when a job fails. Artifact names must be unique per matrix job, so include the Python version. pytest-cov works with xdist and combines coverage from the workers.
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
pytest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -e . pytest pytest-xdist pytest-cov
# -n auto starts one worker per available CPU
- run: pytest -n auto --junitxml=reports/junit.xml --cov=shop --cov-report=xml
- uses: actions/upload-artifact@v7
if: failure()
with:
name: reports-${{ matrix.python-version }}
path: reportsDebugging and keeping the suite fast
pytest remembers failures between runs, so --lf re-runs only the last failures and --ff runs them first. -x stops at the first failure, --pdb drops into the debugger at the point of failure, and -ra prints a summary of skips and xfails with their reasons. --durations=10 lists the slowest tests and fixtures, which is where to look when the suite slows down. Run the suite occasionally with a plugin that randomises test order, to catch tests that pass only because of the order they run in. Treat new warnings as work: in 9.0, the warnings for features scheduled for removal became errors.
Pitfalls
Common pitfalls
Typos in markers that silently select nothing
Why it happens: An unregistered marker such as @pytest.mark.intergration is accepted with only a warning, so -m integration quietly skips the test.
The fix: Register markers in the configuration and enable strict_markers, or strict mode, which turns unknown markers into errors.
Broad-scoped fixtures holding mutable state
Why it happens: A session or module fixture that returns a list, a client with a cache or a database without rollback is shared by every test that uses it, so tests start affecting each other.
The fix: Keep expensive, read-only resources at broad scope and wrap them in function-scoped fixtures that reset or roll back per test.
Copying patterns removed in pytest 8 and 9
Why it happens: Older material uses nose-style setup and teardown methods and pytest.warns(None), removed in 8.0, and marks applied to fixtures or sync tests using async fixtures, removed in 9.0.
The fix: Use setup_method or fixtures instead of nose-style methods, pytest.warns with a warning class, parametrised fixtures through params, and run the suite with -W error before upgrading.
Import errors that depend on where pytest is run
Why it happens: With the default prepend import mode and a flat layout, imports resolve through whichever directory pytest inserts into sys.path, so tests work from one directory and fail from another or in CI.
The fix: Use a src layout with an editable install, or the importlib import mode, and avoid duplicate test module names across directories.
Session fixtures under pytest-xdist
Why it happens: Each xdist worker is a separate process, so a session-scoped fixture runs once per worker, not once per run. A fixture that seeds a shared database then seeds it several times at once.
The fix: Make shared setup idempotent, give each worker its own resource using the worker ID, or perform one-off setup outside pytest before the run.
Using tmpdir in new code
Why it happens: tmpdir and tmpdir_factory return legacy py.path objects, which the documentation now treats as legacy.
The fix: Use tmp_path and tmp_path_factory, which return pathlib.Path, and run with -p no:legacypath to find remaining uses.
Compared
How pytest compares
| Tool | Test style | Setup and teardown | Data-driven tests | Parallel runs | Licence |
|---|---|---|---|---|---|
| pytest | Plain functions with assert | Fixtures injected by argument name | @pytest.mark.parametrize | Via the pytest-xdist plugin | MIT |
| unittest | TestCase classes with assert methods | setUp and tearDown methods | subTest loops only | None built in | Part of Python, PSF licence |
| Robot Framework | Keyword tables in .robot files | Setup and teardown keywords | Test templates | Via Pabot | Apache 2.0 |
Head-to-head comparisons
Pytest vs unittestFAQ
Frequently asked questions
Is pytest free?
Yes. pytest is open source under the MIT licence, with no paid edition or feature tiers. It is maintained by volunteers in the pytest-dev organisation and funded by donations through Open Collective. Companies that want a commercial support agreement can get one through a Tidelift subscription, which the pytest documentation links to as pytest for enterprise.
pytest vs unittest: which should I use?
pytest, for almost any new project. Tests are shorter because they use plain assert, failures show more detail, fixtures handle setup more flexibly than setUp and tearDown, and the plugin ecosystem covers parallel runs and coverage. unittest is in the standard library, which matters only where adding a dependency is impossible. pytest runs unittest suites unchanged, so moving is gradual.
What changed in pytest 9?
pytest 9.0 was released in November 2025 and 9.1 on 13 June 2026. Version 9 dropped Python 3.9, added native TOML configuration in pytest.toml or [tool.pytest], brought subtests into core as an experimental feature, and added a strict option that enables all strictness checks together. It also removed features deprecated in 7.x and 8.x, such as marks applied to fixtures, and made the related warnings errors.
How do I run pytest tests in parallel?
Install pytest-xdist and run pytest -n auto, which starts one worker process per available CPU, or pass a number instead of auto. Tests must be independent, because they are distributed across processes in no fixed order. Session-scoped fixtures run once per worker, so shared resources such as databases need per-worker isolation or idempotent setup.
What is conftest.py for?
conftest.py holds fixtures and hook implementations shared by the tests in its directory and below. pytest loads it automatically, so tests can use its fixtures without importing anything. You can have several, one per directory, each adding fixtures for its own subtree. Keep them small and close to the tests that use them, because deep hierarchies make it hard to find where a fixture comes from.
Can pytest be used for UI and API testing?
Yes. pytest is the usual runner for Python browser automation: Playwright for Python provides a pytest plugin with page and browser fixtures, and Selenium suites use fixtures to create and quit drivers. For APIs, a requests or httpx client in a fixture with parametrised cases covers most needs. pytest supplies structure, fixtures and reporting; the driver or client library does the work.
Is pytest still worth learning?
Yes. It is the standard test runner in the Python ecosystem and the usual base for Python automation with Playwright or Selenium, and it is actively developed, with 9.1 released in June 2026. Its fixture model and parametrisation are also the clearest introduction to ideas that appear in JUnit, Jest and other frameworks.
Alternatives
pytest alternatives worth weighing
Resources
Where to go next
Picking the wrong tool costs more than picking late.
QAble advises on toolchain decisions and then implements them, with engineers who have run these tools on real delivery. Start with a free QA audit.