pytest end-to-end: from your first test to fixtures, parametrize, and CI
A working pytest setup in 60 minutes — install, conftest.py, fixtures, parametrize, pytest-cov and pytest-xdist plugins, and a minimal GitHub Actions matrix.
The bottom line
This tutorial gets a working pytest setup running end-to-end in about 60 minutes: install on Python 3.10 or newer, write a first test, factor reusable setup into fixtures in conftest.py, expand test coverage with @pytest.mark.parametrize, add the pytest-cov and pytest-xdist plugins for coverage reporting and parallel runs, and ship a minimal GitHub Actions matrix that runs the suite on push. The current pytest line is 9.x; pytest 9.0.0 shipped on 8 November 2025 5 and dropped Python 3.9 support, so the supported floor is now Python 3.10 through 3.14. 6 Skip this if you already have a fixtures-based suite running in CI; come back when you need to add parallelism or matrix coverage.
Prerequisites
A Python 3.10+ install (the pytest 9.x line dropped 3.9 support per its PyPI metadata 6 ), a terminal, and a project folder. The examples use a plain venv and pip; the same commands work with uv, poetry, or conda substitutions. No prior pytest experience assumed beyond having written a function and called it.
Step 1: Install pytest and write a first test
Create a project folder, set up a virtual environment, and install pytest from PyPI. 6
mkdir pytest-tutorial && cd pytest-tutorial
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pytest
pytest --version
The last line confirms install. As of 2026-05-19 PyPI lists pytest 9.0.x as the current stable line. 6
Now write the smallest possible test. pytest’s get-started page 1 uses the same pattern: any file named test_*.py with functions named test_* is auto-collected. Create test_basics.py:
# test_basics.py
def add(a, b):
return a + b
def test_add_returns_sum():
assert add(2, 3) == 5
def test_add_handles_negatives():
assert add(-1, 1) == 0
Run it:
pytest
The expected output is 2 passed in 0.0Xs. No unittest.TestCase subclassing, no self.assertEqual, no test runner config; pytest discovers and runs the file directly. The assert statement is rewritten by pytest at collection time so failure output shows the actual values, not just “False”.
Image: pytest on PyPI, used for editorial coverage of the current package version and install metadata.
Step 2: Add fixtures to share setup between tests
When two or more tests need the same setup (a temp file, a database connection, a mocked HTTP client), extract that setup into a fixture. Per the fixtures how-to, 2 a fixture is a function decorated with @pytest.fixture, and a test that takes the fixture’s name as a parameter gets the fixture’s return value injected.
# test_users.py
import pytest
class UserStore:
def __init__(self):
self._users = {}
def add(self, name, email):
self._users[name] = email
def get(self, name):
return self._users.get(name)
@pytest.fixture
def empty_store():
return UserStore()
@pytest.fixture
def store_with_one_user(empty_store):
empty_store.add("ada", "ada@example.com")
return empty_store
def test_empty_store_returns_none(empty_store):
assert empty_store.get("ada") is None
def test_store_with_user_returns_email(store_with_one_user):
assert store_with_one_user.get("ada") == "ada@example.com"
Two patterns are worth naming. First, fixtures compose: store_with_one_user takes empty_store as an argument, which means pytest builds the empty store first, then layers user data on top. Second, fixtures default to function scope, so each test gets a fresh instance. For an expensive setup (a database container, a logged-in browser session), declare a wider scope: @pytest.fixture(scope="module") reuses the same instance across every test in the module, and scope="session" reuses across the entire test run. 2
A fixture that needs teardown uses yield instead of return:
@pytest.fixture
def tmp_log_file(tmp_path):
path = tmp_path / "app.log"
path.write_text("")
yield path
# teardown runs after the test, even if the test failed
if path.exists():
path.unlink()
tmp_path is a pytest-built-in fixture that provides a per-test temp directory; the full list of built-ins is in the fixtures reference. 13
Step 3: Move shared fixtures to conftest.py
When the same fixture is used across multiple test files, move it to conftest.py. Per the fixtures how-to, 2 pytest auto-discovers conftest.py in the test directory and every parent directory; fixtures defined there are available to every test below that directory without import.
Layout:
pytest-tutorial/
├── conftest.py # shared fixtures
├── tests/
│ ├── conftest.py # tests-folder-scoped fixtures
│ ├── test_users.py
│ └── test_billing.py
└── src/
└── app/
conftest.py does not get imported by tests; pytest reads it as part of test collection. 13 Move the empty_store fixture from test_users.py to conftest.py:
# conftest.py
import pytest
class UserStore:
def __init__(self):
self._users = {}
def add(self, name, email):
self._users[name] = email
def get(self, name):
return self._users.get(name)
@pytest.fixture
def empty_store():
return UserStore()
Both test_users.py and test_billing.py can now request empty_store as an argument and receive a fresh instance per test, no import needed. This is the canonical pattern for app-wide test setup: shared infrastructure in the root conftest.py, narrower fixtures in folder-scoped conftest.py, single-use fixtures inline in the test file.
Step 4: Run the same test against many inputs with parametrize
When a test asserts the same shape across a list of inputs, use @pytest.mark.parametrize. Per the parametrize how-to, 3 the decorator takes a comma-separated list of argument names and a list of value tuples; pytest runs the test once per tuple.
# test_math.py
import pytest
def add(a, b):
return a + b
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, -50, 50),
])
def test_add(a, b, expected):
assert add(a, b) == expected
Run pytest test_math.py -v and the output shows four separate test cases: test_add[2-3-5], test_add[0-0-0], and so on. Each runs in isolation; one failing tuple doesn’t short-circuit the others.
Two refinements worth knowing. Custom IDs make the report readable for opaque inputs:
@pytest.mark.parametrize("payload,expected", [
pytest.param({"x": 1}, 1, id="single-key"),
pytest.param({"x": 1, "y": 2}, 3, id="two-keys"),
])
def test_sum_values(payload, expected):
assert sum(payload.values()) == expected
And pytest.param(..., marks=pytest.mark.skip) lets you skip individual rows without removing them from the table. This helps when a row documents an expected failure pinned to a tracked bug.
Fixtures can be parametrized too. Per the parametrize how-to, 3 @pytest.fixture(params=[...]) runs every dependent test once per param value:
@pytest.fixture(params=["postgres", "sqlite"])
def db_backend(request):
return request.param
def test_writes_persist(db_backend):
# runs once for postgres, once for sqlite
...
This is the cleanest pattern for cross-backend test matrices: one fixture, every test that uses it automatically runs across both backends.
Image: pytest-cov on PyPI, used for editorial coverage of the coverage-plugin version metadata.
Step 5: Add coverage with pytest-cov
pytest-cov integrates coverage.py into the pytest run, reporting which lines of your application code were executed by the test suite. As of 2026-05-19 the current version is 7.1.0. 9
pip install pytest-cov
pytest --cov=src --cov-report=term-missing
The --cov=src flag points coverage at the source directory; --cov-report=term-missing prints a per-file table with uncovered line numbers. For CI artefacts, add --cov-report=xml (Codecov, Coveralls) or --cov-report=html (browsable local report). 8
A common gotcha: coverage measures import-time code as covered the moment a test imports the module, which can mask code that’s never actually exercised. Pair --cov-report=term-missing with --cov-branch to also report which branches of every if / else were taken; this catches the “module imported, function never called” pattern that line coverage alone misses.
To configure persistent options, add a pytest section to pyproject.toml:
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-branch"
testpaths = ["tests"]
Now pytest alone runs with coverage. addopts is the canonical place for default CLI flags; testpaths tells pytest where to look for tests so it doesn’t walk the whole tree.
Step 6: Parallelise with pytest-xdist
pytest-xdist distributes tests across multiple processes. For a suite that’s CPU-bound or has independent I/O, this drops wall-clock time roughly linearly with worker count. The current version is 3.8.0. 11
pip install pytest-xdist
pytest -n auto
-n auto uses one worker per CPU core. -n 4 pins to four workers. pytest -n auto --dist=loadfile distributes work in file-sized chunks rather than test-sized chunks, which is the right choice when tests within a file share an expensive module-scoped fixture.
Two compatibility notes. First, pytest-xdist and pytest-cov work together but require a small adjustment: per the pytest-cov xdist docs, 10 coverage data from workers is automatically combined at session end, so pytest -n auto --cov=src produces a unified report. Second, tests that rely on shared mutable state (writing to the same file, mutating a module-level singleton) will fail under parallel runs in non-deterministic ways. Fix the tests, not the parallelism. A test that needs to be the only one running is a smell.
Step 7: Wire it into GitHub Actions
A minimal GitHub Actions workflow that runs the suite on every push, across a Python version matrix, with coverage reporting. Create .github/workflows/tests.yml:
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[test]"
- name: Run tests
run: pytest -n auto --cov=src --cov-report=xml
- name: Upload coverage
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
The setup-python action documentation 14 covers the cache: 'pip' option, which caches the ~/.cache/pip directory keyed on the dependency-file hash; first-run installs cost the full duration, subsequent runs are seconds. Pin the coverage upload to a single Python version to avoid four separate uploads racing on the same commit.
fail-fast: false is opinionated: it tells the matrix to keep running every Python version even after one fails, so a single environment-specific bug doesn’t hide failures elsewhere. Drop it back to default true if your team wants the cheaper “stop on first failure” cost profile.
Image: pytest-xdist on PyPI, used for editorial coverage of the parallel-testing plugin metadata.
Common gotchas
A short list of the failure modes that bite first-time pytest users.
test_*.py naming is mandatory. pytest collects files matching test_*.py or *_test.py by default. 1 Files named tests.py or my_tests.py are silently ignored. The discovery rules are configurable in pyproject.toml under python_files, but the default is the path of least surprise.
Fixtures must be requested by name, not imported. A test that imports a fixture from conftest.py will fail because the fixture function isn’t meant to be called directly; pytest calls it during test setup. The right pattern is just declaring the fixture name as a test parameter. 2
conftest.py is per-directory. A fixture in tests/api/conftest.py is visible to tests in tests/api/ and below, but not to tests in tests/billing/. To share across both, move the fixture to tests/conftest.py or the project root. 13
Parametrize IDs leak into CI logs. When parametrizing with complex objects (dicts, lists), pytest generates auto-IDs that include the repr of each value, which can be both unreadable and a source of test-name churn between runs. Use pytest.param(..., id="descriptive-label") to pin readable IDs. 3
Coverage shows 100% but tests don’t actually exercise the code. Module import counts as line coverage. Combine --cov-report=term-missing with --cov-branch to surface branches that import-time coverage hides. 8
xdist breaks tests that share state. A test suite that passes serially and fails under pytest -n auto has hidden coupling. The pytest-xdist documentation 10 recommends marking specifically-non-parallel tests with @pytest.mark.serial and excluding them with a -m "not serial" filter on the parallel run, then running them in a separate sequential pass.
Image: GitHub Actions setup-python repository, used for editorial coverage of the CI Python-setup integration.
Where to go next
Three plugin extensions cover most of what’s beyond this tutorial. pytest-mock wraps unittest.mock in a fixture-friendly API: instead of nesting with patch(...) blocks, request a mocker fixture and call mocker.patch(...) directly. pytest-asyncio is the canonical plugin for async test functions; it provides an event-loop fixture and supports both function-scoped and session-scoped loops. pytest-django (for Django apps) and pytest-flask (for Flask) provide framework-specific fixtures: a client for HTTP requests, a db fixture that wraps transactions for rollback after each test.
For the pytest version-line story, the pytest-9.0.0 announcement 5 spells out the breaking changes: dropped Python 3.9, PytestRemovedIn9Warning deprecations are now errors, and non-unique parametrize IDs raise an error rather than auto-suffixing. The pytest changelog 4 is the authoritative reference for everything since.
The pytest source itself 7 is unusually readable for a project of its size; the conftest.py files inside testing/ are a working example of every fixture pattern the docs describe.
For test selection at the command line, pytest’s filtering primitives compose: pytest -k "users and not billing" runs tests matching the keyword expression, pytest tests/api/ runs everything under a path, pytest tests/api/test_users.py::test_login runs a single function, and pytest -m "slow" runs only tests tagged with @pytest.mark.slow. The plugins-and-marks doc 12 covers the full marker syntax. In CI, a common pattern is to split a slow integration suite (-m slow) into a separate workflow that runs nightly, while the unit suite (-m "not slow") runs on every push.
One last operational pattern worth flagging: pytest --lf reruns only the tests that failed in the previous run, and pytest --ff runs the failed tests first, then the rest. Both modes pay off when iterating on a single broken test inside a large suite. The state is stored in .pytest_cache/, which should be added to .gitignore.
How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.
Sources consulted
Cited Sources
- 1. pytest documentation — Get Started page; default test-discovery rules and first-test pattern. (accessed ) ↩
- 2. pytest documentation — How to use fixtures; conftest.py auto-discovery and fixture-scope semantics. (accessed ) ↩
- 3. pytest documentation — How to parametrize fixtures and test functions. (accessed ) ↩
- 4. pytest documentation — Changelog. (accessed ) ↩
- 5. pytest 9.0.0 release announcement — 8 November 2025; Python 3.9 dropped, breaking changes. (accessed ) ↩
- 6. pytest on PyPI — current version, Python-version classifiers, install metadata. (accessed ) ↩
- 7. pytest source repository on GitHub. (accessed ) ↩
- 8. pytest-cov documentation — coverage integration, report formats, branch coverage. (accessed ) ↩
- 9. pytest-cov on PyPI — current version 7.1.0, release date 2026-03-21. (accessed ) ↩
- 10. pytest-xdist documentation — distribution modes, worker semantics, coverage integration. (accessed ) ↩
- 11. pytest-xdist on PyPI — current version 3.8.0. (accessed ) ↩
- 12. pytest documentation — How to install and use plugins. (accessed ) ↩
- 13. pytest documentation — Fixtures reference; built-in fixtures including tmp_path, conftest.py scoping. (accessed ) ↩
- 14. GitHub Actions — setup-python action; matrix configuration, pip caching. (accessed ) ↩
Anonymous · no cookies set